diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 32538a80c77d..f2c05356f814 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -53,6 +53,8 @@ from course_creators.views import get_course_creator_status, add_user_with_status_unrequested from contentstore import utils +from microsite_configuration.middleware import MicrositeConfiguration + __all__ = ['course_info_handler', 'course_handler', 'course_info_update_handler', 'settings_handler', 'grading_handler', @@ -414,15 +416,19 @@ def settings_handler(request, tag=None, course_id=None, branch=None, version_gui if 'text/html' in request.META.get('HTTP_ACCEPT', '') and request.method == 'GET': upload_asset_url = locator.url_reverse('assets/') + # see if the ORG of this course can be attributed to a 'Microsite'. In that case, the + # course about page should be editable in Studio + about_page_editable = not MicrositeConfiguration.get_microsite_configuration_value_for_org( + course_module.location.org, 'ENABLE_MKTG_SITE', settings.FEATURES.get( + 'ENABLE_MKTG_SITE', False)) + return render_to_response('settings.html', { 'context_course': course_module, 'course_locator': locator, 'lms_link_for_about_page': utils.get_lms_link_for_about_page(course_module.location), 'course_image_url': utils.course_image_url(course_module), 'details_url': locator.url_reverse('/settings/details/'), - 'about_page_editable': not settings.FEATURES.get( - 'ENABLE_MKTG_SITE', False - ), + 'about_page_editable': about_page_editable, 'upload_asset_url': upload_asset_url }) elif 'application/json' in request.META.get('HTTP_ACCEPT', ''): diff --git a/cms/envs/aws.py b/cms/envs/aws.py index 1ee1c3be7775..9ee55db4a836 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -145,7 +145,6 @@ #Timezone overrides TIME_ZONE = ENV_TOKENS.get('TIME_ZONE', TIME_ZONE) - ENV_FEATURES = ENV_TOKENS.get('FEATURES', ENV_TOKENS.get('MITX_FEATURES', {})) for feature, value in ENV_FEATURES.items(): FEATURES[feature] = value @@ -213,3 +212,5 @@ # Event tracking TRACKING_BACKENDS.update(AUTH_TOKENS.get("TRACKING_BACKENDS", {})) + +MICROSITE_CONFIGURATION = ENV_TOKENS.get('MICROSITE_CONFIGURATION', {}) diff --git a/cms/envs/common.py b/cms/envs/common.py index 1d2dd3040838..bb7b121a981c 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -26,6 +26,7 @@ import sys import lms.envs.common from lms.envs.common import USE_TZ, TECH_SUPPORT_EMAIL, PLATFORM_NAME, BUGS_EMAIL +from lms.envs.common import MICROSITE_CONFIGURATION as _MICROSITE_CONFIGURATION from path import path from lms.lib.xblock.mixin import LmsBlockMixin @@ -448,3 +449,6 @@ 'url': "http://video.google.com/timedtext", 'params': {'lang': 'en', 'v': 'set_youtube_id_of_11_symbols_here'} } + +# pull in the Microsite definitions which are in a separate file +MICROSITE_CONFIGURATION = _MICROSITE_CONFIGURATION \ No newline at end of file diff --git a/cms/envs/dev.py b/cms/envs/dev.py index 74561cee636f..0666fc951895 100644 --- a/cms/envs/dev.py +++ b/cms/envs/dev.py @@ -199,3 +199,5 @@ from .private import * # pylint: disable=F0401 except ImportError: pass + + diff --git a/common/djangoapps/microsite_configuration/__init__.py b/common/djangoapps/microsite_configuration/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/common/djangoapps/microsite_configuration/middleware.py b/common/djangoapps/microsite_configuration/middleware.py new file mode 100644 index 000000000000..d64039c1b72f --- /dev/null +++ b/common/djangoapps/microsite_configuration/middleware.py @@ -0,0 +1,190 @@ +""" +This file implements the initial Microsite support for the Open edX platform. +A microsite enables the following features: + +1) Mapping of sub-domain name to a 'brand', e.g. foo-university.edx.org +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 + +from django.conf import settings + +from mako.template import Template +from mako.runtime import Context +from StringIO import StringIO + +_microsite_configuration_threadlocal = threading.local() +_microsite_configuration_threadlocal.data = {} + +def has_microsite_configuration_set(): + """ + Returns whether the MICROSITE_CONFIGURATION has been set in the configuration files + """ + return hasattr(settings, "MICROSITE_CONFIGURATION") and settings.MICROSITE_CONFIGURATION + + +class MicrositeConfiguration(object): + """ + Middleware class which will bind configuration information regarding 'microsites' on a per request basis. + The actual configuration information is taken from Django settings information + """ + + @classmethod + def is_request_in_microsite(cls): + """ + This will return if current request is a request within a microsite + """ + return cls.get_microsite_configuration() != None + + @classmethod + def has_microsite_email_template_definition(cls, template_name): + """ + """ + return cls.is_request_in_microsite() and cls.get_microsite_email_template_definitions( + template_name) != None + + @classmethod + def get_microsite_configuration(cls): + """ + Returns the current request's microsite configuration + """ + return _microsite_configuration_threadlocal.data + + @classmethod + def get_microsite_configuration_value(cls, val_name, default=None): + """ + Returns a value associated with the request's microsite, if present + """ + configuration = cls.get_microsite_configuration() + return configuration.get(val_name, default) + + @classmethod + def get_microsite_email_template_definitions(cls, template_name): + """ + Returns the template definitions associated with a Microsite + """ + configuration = cls.get_microsite_configuration() + if configuration and 'email_templates' in configuration: + return configuration['email_templates'].get(template_name, None) + + return None + + @classmethod + def render_microsite_email_template(cls, template_name, params): + """ + Returns a string pair which is a rendered version of an email template of a given name + """ + subject = None + message = None + + email_template_definitions = cls.get_microsite_email_template_definitions(template_name) + + if email_template_definitions: + # inject a few additional parameters that should be available to all + # email templates + p = params.copy() + p['site_domain'] = cls.get_microsite_configuration_value('site_domain') + p['platform_name'] = cls.get_microsite_configuration_value('platform_name') + + buf = StringIO() + ctx = Context(buf, **p) + + subject_template = Template(email_template_definitions.get('subject', None)) + if subject_template: + subject_template.render_context(ctx) + subject = buf.getvalue() + + buf.truncate(0) + message_template = Template(email_template_definitions.get('body', None)) + if message_template: + message_template.render_context(ctx) + message = buf.getvalue() + + return subject, message + + @classmethod + def get_microsite_configuration_value_for_org(cls, 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_microsite_configuration_set(): + return default + + for key in settings.MICROSITE_CONFIGURATION.keys(): + org_filter = settings.MICROSITE_CONFIGURATION[key].get('course_org_filter', None) + if org_filter == org: + return settings.MICROSITE_CONFIGURATION[key].get(val_name, default) + return default + + def clear_microsite_configuration(self): + """ + Clears out any microsite configuration from the current request/thread + """ + _microsite_configuration_threadlocal.data = {} + + def process_request(self, request): + """ + Middleware entry point on every request processing. This will associate a request's domain name + with a 'Univserity' and any corresponding microsite configuration information + """ + self.clear_microsite_configuration() + + domain = request.META.get('HTTP_HOST', None) + + if settings.FEATURES['SUBDOMAIN_BRANDING'] and domain: + subdomain = self.pick_subdomain(domain, settings.SUBDOMAIN_BRANDING.keys()) + university = self.match_university(subdomain) + microsite_configuration = self.get_microsite_configuration_for_university(university) + if microsite_configuration: + microsite_configuration['university'] = university + microsite_configuration['subdomain'] = subdomain + microsite_configuration['site_domain'] = domain + _microsite_configuration_threadlocal.data = microsite_configuration + + # also put the configuration on the request itself to make it easier to dereference + request.microsite_configuration = _microsite_configuration_threadlocal.data + return None + + def process_response(self, request, response): + """ + Middleware entry point for request completion. + """ + self.clear_microsite_configuration() + return response + + def get_microsite_configuration_for_university(self, university): + """ + For a given university, return the microsite configuration which + is in the Django settings + """ + if not university: + return None + + if not has_microsite_configuration_set(): + return None + + configuration = settings.MICROSITE_CONFIGURATION.get(university, None) + return configuration + + def match_university(self, domain): + """ + Return the university name specified for the domain, or None + if no university was specified + """ + if not settings.FEATURES['SUBDOMAIN_BRANDING'] or domain is None: + return None + + subdomain = self.pick_subdomain(domain, settings.SUBDOMAIN_BRANDING.keys()) + return settings.SUBDOMAIN_BRANDING.get(subdomain) + + def pick_subdomain(self, domain, options, default='default'): + """ + Attempt to match the incoming request's HOST domain with a configuration map + to see what subdomains are supported in Microsites. + """ + for option in options: + if domain.startswith(option): + return option + return default diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index a0cf27d786df..0f582bb993b4 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -36,6 +36,9 @@ from ratelimitbackend.exceptions import RateLimitException from edxmako.shortcuts import render_to_response, render_to_string +from edxmako.template import Template +from mako.runtime import Context +from StringIO import StringIO from course_modes.models import CourseMode from student.models import ( @@ -71,6 +74,7 @@ from util.json_request import JsonResponse +from microsite_configuration.middleware import MicrositeConfiguration log = logging.getLogger("edx.student") AUDIT_LOG = logging.getLogger("audit") @@ -311,9 +315,22 @@ def dashboard(request): # longer exist (because the course IDs have changed). Still, we don't delete those # enrollments, because it could have been a data push snafu. course_enrollment_pairs = [] + + # for microsites, we want to filter and only show enrollments for courses within + # the microsites 'ORG' + + course_org_filter = MicrositeConfiguration.get_microsite_configuration_value('course_org_filter') + show_only_org_on_student_dashboard = MicrositeConfiguration.get_microsite_configuration_value( + 'show_only_org_on_student_dashboard') + for enrollment in CourseEnrollment.enrollments_for_user(user): try: - course_enrollment_pairs.append((course_from_id(enrollment.course_id), enrollment)) + course = course_from_id(enrollment.course_id) + + if course_org_filter and show_only_org_on_student_dashboard and course_org_filter != course.location.org: + continue + + course_enrollment_pairs.append((course, enrollment)) except ItemNotFoundError: log.error("User {0} enrolled in non-existent course {1}" .format(user.username, enrollment.course_id)) @@ -903,22 +920,29 @@ def create_account(request, post_override=None): 'key': registration.activation_key, } - # composes activation email - subject = render_to_string('emails/activation_email_subject.txt', d) - # Email subject *must not* contain newlines - subject = ''.join(subject.splitlines()) - message = render_to_string('emails/activation_email.txt', d) + # see if we are running in a microsite and that there is an + # activation email template definition available as configuration, if so, then render that + if MicrositeConfiguration.has_microsite_email_template_definition('activation_email'): + subject, message = MicrositeConfiguration.render_microsite_email_template('activation_email', d) + else: + # composes activation email + subject = render_to_string('emails/activation_email_subject.txt', d) + # Email subject *must not* contain newlines + subject = ''.join(subject.splitlines()) + message = render_to_string('emails/activation_email.txt', d) # don't send email if we are doing load testing or random user generation for some reason if not (settings.FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING')): + from_address = MicrositeConfiguration.get_microsite_configuration_value('email_from_address', + settings.DEFAULT_FROM_EMAIL) try: if settings.FEATURES.get('REROUTE_ACTIVATION_EMAIL'): dest_addr = settings.FEATURES['REROUTE_ACTIVATION_EMAIL'] message = ("Activation for %s (%s): %s\n" % (user, user.email, profile.name) + '-' * 80 + '\n\n' + message) - send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, [dest_addr], fail_silently=False) + send_mail(subject, message, from_address, [dest_addr], fail_silently=False) else: - _res = user.email_user(subject, message, settings.DEFAULT_FROM_EMAIL) + _res = user.email_user(subject, message, from_address) except: log.warning('Unable to send activation email to user', exc_info=True) js['value'] = _('Could not send activation e-mail.') @@ -1175,11 +1199,27 @@ def change_email_request(request): 'old_email': user.email, 'new_email': pec.new_email} - subject = render_to_string('emails/email_change_subject.txt', d) + # see if there are email templates defined for this microsite + email_templates = MicrositeConfiguration.get_microsite_configuration_value( + 'email_template_files' + ) + + if email_templates: + subject_template, message_template = email_templates['email_change'] + else: + # fallback to default system templates + subject_template = 'emails/email_change_subject.txt' + message_template = 'emails/email_change.txt' + + subject = render_to_string(subject_template, d) subject = ''.join(subject.splitlines()) - message = render_to_string('emails/email_change.txt', d) - _res = send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, [pec.new_email]) + message = render_to_string(message_template, d) + + from_address = MicrositeConfiguration.get_microsite_configuration_value('email_from_address', + settings.DEFAULT_FROM_EMAIL) + + _res = send_mail(subject, message, from_address, [pec.new_email]) return HttpResponse(json.dumps({'success': True})) diff --git a/lms/djangoapps/branding/__init__.py b/lms/djangoapps/branding/__init__.py index d70ffb1cc990..6e9535057cc4 100644 --- a/lms/djangoapps/branding/__init__.py +++ b/lms/djangoapps/branding/__init__.py @@ -2,15 +2,10 @@ from xmodule.course_module import CourseDescriptor from django.conf import settings +from microsite_configuration.middleware import MicrositeConfiguration -def pick_subdomain(domain, options, default='default'): - for option in options: - if domain.startswith(option): - return option - return default - -def get_visible_courses(domain=None): +def get_visible_courses(): """ Return the set of CourseDescriptors that should be visible in this branded instance """ @@ -20,31 +15,55 @@ def get_visible_courses(domain=None): if isinstance(c, CourseDescriptor)] courses = sorted(courses, key=lambda course: course.number) - if domain and settings.FEATURES.get('SUBDOMAIN_COURSE_LISTINGS'): - subdomain = pick_subdomain(domain, settings.COURSE_LISTINGS.keys()) - visible_ids = frozenset(settings.COURSE_LISTINGS[subdomain]) - return [course for course in courses if course.id in visible_ids] + subdomain = MicrositeConfiguration.get_microsite_configuration_value('subdomain') + + # See if we have filtered course listings in this domain + filtered_visible_ids = None + + # this is legacy format which is outside of the microsite feature + if hasattr(settings, 'COURSE_LISTINGS') and subdomain in settings.COURSE_LISTINGS: + filtered_visible_ids = frozenset(settings.COURSE_LISTINGS[subdomain]) + + filtered_by_org = MicrositeConfiguration.get_microsite_configuration_value('course_org_filter') + + if filtered_by_org: + return [course for course in courses if course.location.org == filtered_by_org] + if filtered_visible_ids: + return [course for course in courses if course.id in filtered_visible_ids] else: return courses -def get_university(domain=None): +def get_university_for_request(): """ Return the university name specified for the domain, or None if no university was specified """ - if not settings.FEATURES['SUBDOMAIN_BRANDING'] or domain is None: - return None - - subdomain = pick_subdomain(domain, settings.SUBDOMAIN_BRANDING.keys()) - return settings.SUBDOMAIN_BRANDING.get(subdomain) + return MicrositeConfiguration.get_microsite_configuration_value('university') -def get_logo_url(domain=None): +def get_logo_url(): """ Return the url for the branded logo image to be used """ - university = get_university(domain) + + # if the MicrositeConfiguration has a value for the logo_image_url + # let's use that + image_url = MicrositeConfiguration.get_microsite_configuration_value('logo_image_url') + if image_url: + return image_url + + # else, see if a logo file (in our repo) has been specified, if so, use that + image_file = MicrositeConfiguration.get_microsite_configuration_value('logo_image_file') + + if image_file: + return '{static_url}images/{image_file}'.format( + static_url=settings.STATIC_URL, + image_file=image_file + ) + + # otherwise, use the legacy means to configure this + university = MicrositeConfiguration.get_microsite_configuration_value('university') if university is None: return '{static_url}images/header-logo.png'.format( diff --git a/lms/djangoapps/branding/views.py b/lms/djangoapps/branding/views.py index b725f7e1996a..32c2aab518d4 100644 --- a/lms/djangoapps/branding/views.py +++ b/lms/djangoapps/branding/views.py @@ -6,8 +6,9 @@ from edxmako.shortcuts import render_to_response import student.views -import branding import courseware.views + +from microsite_configuration.middleware import MicrositeConfiguration from edxmako.shortcuts import marketing_link from util.cache import cache_if_anonymous @@ -25,12 +26,16 @@ def index(request): if settings.FEATURES.get('AUTH_USE_MIT_CERTIFICATES'): from external_auth.views import ssl_login return ssl_login(request) - if settings.FEATURES.get('ENABLE_MKTG_SITE'): + + enable_mktg_site = settings.FEATURES.get('ENABLE_MKTG_SITE') or MicrositeConfiguration.get_microsite_configuration_value('ENABLE_MKTG_SITE', False) + + if enable_mktg_site: return redirect(settings.MKTG_URLS.get('ROOT')) - university = branding.get_university(request.META.get('HTTP_HOST')) - if university == 'edge': - return render_to_response('university_profile/edge.html', {}) + custom_landing_page_template = MicrositeConfiguration.get_microsite_configuration_value('university_profile_template') + + if custom_landing_page_template: + return render_to_response(custom_landing_page_template, {}) # we do not expect this case to be reached in cases where # marketing and edge are enabled @@ -46,7 +51,9 @@ def courses(request): to that. Otherwise, if subdomain branding is on, this is the university profile page. Otherwise, it's the edX courseware.views.courses page """ - if settings.FEATURES.get('ENABLE_MKTG_SITE', False): + enable_mktg_site = settings.FEATURES.get('ENABLE_MKTG_SITE') or MicrositeConfiguration.get_microsite_configuration_value('ENABLE_MKTG_SITE', False) + + if enable_mktg_site: return redirect(marketing_link('COURSES'), permanent=True) if not settings.FEATURES.get('COURSES_ARE_BROWSABLE'): diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index c74b0f9e6f01..fd8adf998b6b 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -294,7 +294,7 @@ def get_courses(user, domain=None): ''' Returns a list of courses available, sorted by course.number ''' - courses = branding.get_visible_courses(domain) + courses = branding.get_visible_courses() courses = [c for c in courses if has_access(user, c, 'see_exists')] courses = sorted(courses, key=lambda course: course.number) diff --git a/lms/djangoapps/instructor/enrollment.py b/lms/djangoapps/instructor/enrollment.py index a7c4fe0b6554..74bb235fd97c 100644 --- a/lms/djangoapps/instructor/enrollment.py +++ b/lms/djangoapps/instructor/enrollment.py @@ -14,6 +14,8 @@ from courseware.models import StudentModule from edxmako.shortcuts import render_to_string +from microsite_configuration.middleware import MicrositeConfiguration + # For determining if a shibboleth course SHIBBOLETH_DOMAIN_PREFIX = 'shib:' @@ -223,22 +225,44 @@ def send_mail_to_student(student, param_dict): Returns a boolean indicating whether the email was sent successfully. """ - email_template_dict = {'allowed_enroll': ('emails/enroll_email_allowedsubject.txt', 'emails/enroll_email_allowedmessage.txt'), - 'enrolled_enroll': ('emails/enroll_email_enrolledsubject.txt', 'emails/enroll_email_enrolledmessage.txt'), - 'allowed_unenroll': ('emails/unenroll_email_subject.txt', 'emails/unenroll_email_allowedmessage.txt'), - 'enrolled_unenroll': ('emails/unenroll_email_subject.txt', 'emails/unenroll_email_enrolledmessage.txt')} + # add some helpers and microconfig subsitutions + if 'course' in param_dict: + param_dict['course_name'] = param_dict['course'].display_name_with_default + + param_dict['site_name'] = MicrositeConfiguration.get_microsite_configuration_value('SITE_NAME', + param_dict['site_name']) + + subject = None + message = None + + # see if we are running in a microsite and that there is an + # activation email template definition available as configuration, if so, then render that + message_type = param_dict['message'] + if MicrositeConfiguration.has_microsite_email_template_definition(message_type): + subject, message = MicrositeConfiguration.render_microsite_email_template(message_type, + param_dict) + else: # use the on-disk email templates in lms/templates/email + email_template_dict = {'allowed_enroll': ('emails/enroll_email_allowedsubject.txt', 'emails/enroll_email_allowedmessage.txt'), + 'enrolled_enroll': ('emails/enroll_email_enrolledsubject.txt', 'emails/enroll_email_enrolledmessage.txt'), + 'allowed_unenroll': ('emails/unenroll_email_subject.txt', 'emails/unenroll_email_allowedmessage.txt'), + 'enrolled_unenroll': ('emails/unenroll_email_subject.txt', 'emails/unenroll_email_enrolledmessage.txt') + } - subject_template, message_template = email_template_dict.get(param_dict['message'], (None, None)) - if subject_template is not None and message_template is not None: - subject = render_to_string(subject_template, param_dict) - message = render_to_string(message_template, param_dict) + subject_template, message_template = email_template_dict.get(message_type, (None, None)) + if subject_template is not None and message_template is not None: + subject = render_to_string(subject_template, param_dict) + message = render_to_string(message_template, param_dict) + if subject and message: # Remove leading and trailing whitespace from body message = message.strip() # Email subject *must not* contain newlines subject = ''.join(subject.splitlines()) - send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, [student], fail_silently=False) + from_address = MicrositeConfiguration.get_microsite_configuration_value('email_from_address', + settings.DEFAULT_FROM_EMAIL) + + send_mail(subject, message, from_address, [student], fail_silently=False) def uses_shib(course): diff --git a/lms/djangoapps/instructor/views/legacy.py b/lms/djangoapps/instructor/views/legacy.py index 51c29da728eb..a7eb4b504be4 100644 --- a/lms/djangoapps/instructor/views/legacy.py +++ b/lms/djangoapps/instructor/views/legacy.py @@ -63,6 +63,8 @@ from django.utils.translation import ugettext as _u from lms.lib.xblock.runtime import handler_prefix +from microsite_configuration.middleware import MicrositeConfiguration + log = logging.getLogger(__name__) # internal commands for managing forum roles: @@ -1275,7 +1277,8 @@ def _do_enroll_students(course, course_id, students, overload=False, auto_enroll ceaset.delete() if email_students: - stripped_site_name = settings.SITE_NAME + stripped_site_name = MicrositeConfiguration.get_microsite_configuration_value('SITE_NAME', + settings.SITE_NAME) registration_url = 'https://' + stripped_site_name + reverse('student.views.register_user') #Composition of email d = {'site_name': stripped_site_name, @@ -1284,7 +1287,7 @@ def _do_enroll_students(course, course_id, students, overload=False, auto_enroll 'auto_enroll': auto_enroll, 'course_url': 'https://' + stripped_site_name + '/courses/' + course_id, 'course_about_url': 'https://' + stripped_site_name + '/courses/' + course_id + '/about', - 'is_shib_course': is_shib_course, + 'is_shib_course': is_shib_course } for student in new_students: @@ -1366,7 +1369,8 @@ def _do_unenroll_students(course_id, students, email_students=False): old_students, _ = get_and_clean_student_list(students) status = dict([x, 'unprocessed'] for x in old_students) - stripped_site_name = settings.SITE_NAME + stripped_site_name = MicrositeConfiguration.get_microsite_configuration_value('SITE_NAME', + settings.SITE_NAME) if email_students: course = course_from_id(course_id) #Composition of email @@ -1440,22 +1444,43 @@ def send_mail_to_student(student, param_dict): Returns a boolean indicating whether the email was sent successfully. """ - EMAIL_TEMPLATE_DICT = {'allowed_enroll': ('emails/enroll_email_allowedsubject.txt', 'emails/enroll_email_allowedmessage.txt'), - 'enrolled_enroll': ('emails/enroll_email_enrolledsubject.txt', 'emails/enroll_email_enrolledmessage.txt'), - 'allowed_unenroll': ('emails/unenroll_email_subject.txt', 'emails/unenroll_email_allowedmessage.txt'), - 'enrolled_unenroll': ('emails/unenroll_email_subject.txt', 'emails/unenroll_email_enrolledmessage.txt')} + # add some helpers and microconfig subsitutions + if 'course' in param_dict: + param_dict['course_name'] = param_dict['course'].display_name_with_default + param_dict['site_name'] = MicrositeConfiguration.get_microsite_configuration_value('SITE_NAME', + param_dict.get('site_name', '')) + + subject = None + message = None + + # see if we are running in a microsite and that there is an + # activation email template definition available as configuration, if so, then render that + message_type = param_dict['message'] + if MicrositeConfiguration.has_microsite_email_template_definition(message_type): + subject, message = MicrositeConfiguration.render_microsite_email_template(message_type, + param_dict) + else: # use the on-disk email templates in lms/templates/email + email_template_dict = {'allowed_enroll': ('emails/enroll_email_allowedsubject.txt', 'emails/enroll_email_allowedmessage.txt'), + 'enrolled_enroll': ('emails/enroll_email_enrolledsubject.txt', 'emails/enroll_email_enrolledmessage.txt'), + 'allowed_unenroll': ('emails/unenroll_email_subject.txt', 'emails/unenroll_email_allowedmessage.txt'), + 'enrolled_unenroll': ('emails/unenroll_email_subject.txt', 'emails/unenroll_email_enrolledmessage.txt') + } - subject_template, message_template = EMAIL_TEMPLATE_DICT.get(param_dict['message'], (None, None)) - if subject_template is not None and message_template is not None: - subject = render_to_string(subject_template, param_dict) - message = render_to_string(message_template, param_dict) + subject_template, message_template = email_template_dict.get(message_type, (None, None)) + if subject_template is not None and message_template is not None: + subject = render_to_string(subject_template, param_dict) + message = render_to_string(message_template, param_dict) + if subject and message: # Remove leading and trailing whitespace from body message = message.strip() # Email subject *must not* contain newlines subject = ''.join(subject.splitlines()) - send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, [student], fail_silently=False) + from_address = MicrositeConfiguration.get_microsite_configuration_value('email_from_address', + settings.DEFAULT_FROM_EMAIL) + + send_mail(subject, message, from_address, [student], fail_silently=False) return True else: diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 7647b2c48708..f3cff1c766e2 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -32,6 +32,8 @@ from .exceptions import (InvalidCartItem, PurchasedCallbackException, ItemAlreadyInCartException, AlreadyEnrolledInCourseException, CourseDoesNotExistException) +from microsite_configuration.middleware import MicrositeConfiguration + log = logging.getLogger("shoppingcart") ORDER_STATUSES = ( @@ -167,8 +169,11 @@ def purchase(self, first='', last='', street1='', street2='', city='', state='', 'has_billing_info': settings.FEATURES['STORE_BILLING_INFO'] }) try: + from_address = MicrositeConfiguration.get_microsite_configuration_value('email_from_address', + settings.DEFAULT_FROM_EMAIL) + send_mail(subject, message, - settings.DEFAULT_FROM_EMAIL, [self.user.email]) # pylint: disable=E1101 + from_address, [self.user.email]) # pylint: disable=E1101 except (smtplib.SMTPException, BotoServerError): # sadly need to handle diff. mail backends individually log.error('Failed sending confirmation e-mail for order %d', self.id) # pylint: disable=E1101 @@ -523,7 +528,8 @@ def refund_cert_callback(sender, course_enrollment=None, **kwargs): user_email=course_enrollment.user.email, order_number=order_number) to_email = [settings.PAYMENT_SUPPORT_EMAIL] - from_email = [settings.PAYMENT_SUPPORT_EMAIL] + from_email = [MicrositeConfiguration.get_microsite_configuration_value('payment_support_email', + settings.PAYMENT_SUPPORT_EMAIL)] try: send_mail(subject, message, from_email, to_email, fail_silently=False) except (smtplib.SMTPException, BotoServerError): diff --git a/lms/envs/aws.py b/lms/envs/aws.py index 0e3e87b299dc..784be625dc62 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -335,3 +335,6 @@ GRADES_DOWNLOAD_ROUTING_KEY = HIGH_MEM_QUEUE GRADES_DOWNLOAD = ENV_TOKENS.get("GRADES_DOWNLOAD", GRADES_DOWNLOAD) + +MICROSITE_CONFIGURATION = ENV_TOKENS.get('MICROSITE_CONFIGURATION', {}) + diff --git a/lms/envs/common.py b/lms/envs/common.py index 893e537f770c..7ea391dc88a2 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -34,6 +34,8 @@ from xmodule.modulestore.inheritance import InheritanceMixin from xmodule.x_module import XModuleMixin +from .microsite import _MICROSITE_CONFIGURATION + ################################### FEATURES ################################### # The display name of the platform to be used in templates/emails/etc. PLATFORM_NAME = "edX" @@ -603,6 +605,7 @@ MIDDLEWARE_CLASSES = ( 'request_cache.middleware.RequestCache', 'django_comment_client.middleware.AjaxExceptionMiddleware', + 'microsite_configuration.middleware.MicrositeConfiguration', 'django.middleware.common.CommonMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', @@ -1083,3 +1086,6 @@ def enable_theme(theme_name): 'BUCKET': 'edx-grades', 'ROOT_PATH': '/tmp/edx-s3/grades', } + +# pull in the Microsite definitions which are in a separate file +MICROSITE_CONFIGURATION = _MICROSITE_CONFIGURATION diff --git a/lms/envs/dev.py b/lms/envs/dev.py index bfff68157e1b..ca476d341d0b 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -126,6 +126,8 @@ 'mit': 'MITx', 'berkeley': 'BerkeleyX', 'harvard': 'HarvardX', + 'openedx': 'openedx', + 'edge': 'edge' } # List of `university` landing pages to display, even though they may not @@ -287,3 +289,5 @@ from .private import * # pylint: disable=F0401 except ImportError: pass + + diff --git a/lms/envs/microsite.py b/lms/envs/microsite.py new file mode 100644 index 000000000000..651d5c5270b6 --- /dev/null +++ b/lms/envs/microsite.py @@ -0,0 +1,212 @@ +# Configuration data to support 'microsites' that are bound - in middleware - +# based on incoming hostname domain +# For local test purposes, let's define a "OpenedX" subdomain brand +_MICROSITE_CONFIGURATION = { + "openedx": { + # A display string that will be displayed when user visits the microsite. Given that we are handling this + # in configuration, it will not be localizable + "platform_name": "Open edX", + # override for USE_CUSTOM_THEME set otherwhere in config + "USE_CUSTOM_THEME": True, + # override for THEME_NAME set otherwhere in config, which is part of the + # Stanford Theming contribution + "THEME_NAME": "openedx", + # allow for microsite to load in an additional .css file that can override + # the platform's default CSS definitions. For example, swap out the background image. + "css_overrides_file": "microsites/openedx/css/openedx.css", + # This is a CSS literal which will be added to every page + "css_overrides": None, + # what logo file to display, note that we shouldn't check in trademarked logos + # into the repository, so these images will have to be deployed/managed outside of + # code pushes + "logo_image_file": "open_edX_logo.png", + # also allow for a specification of a separate URL for the logo image so that we don't + # need to check it into the repo + "logo_image_url": None, + # what filter to use when displaying the course catalog on the landing page + "course_org_filter": "CDX", + # filter dashboard to only show this ORG's courses? + "show_only_org_on_student_dashboard": True, + # email from field on outbound emails + "email_from_address": "openedx@edx.org", + "payment_support_email": "openedx@edx.org", + # override for ENABLE_MKTG_SITE set otherwhere in config + # this is to indicate that the LMS is 'behind' a separate marketing website which + # is a different web application + "ENABLE_MKTG_SITE": False, + # override the SITE_NAME setting + "SITE_NAME": 'openedx.localhost', + # setting to indicate whether to show the "university partner" list on the landing page + "show_university_partners": False, + # setting to hide the promo video on the homepage + "show_homepage_promo_video": False, + # These 4 following items define the template substitutions to use in the courseware pages + # these templates reside in lms/templates + "header_extra_file": None, + "header_file": "navigation.html", + "google_analytics_file": "google_analytics.html", + "footer_file": "openedx-footer.html", + # This override is a HTML literal to put as the page footer, unfortunately this HTML becomes + # non-localizable if we define in configuration. NOTE: This will take presendence over 'footer_file' + "footer_html": """ + + """, + # this control whether the home header (the overlay) shows on the homepage + # for example the overlay which says "The Future of Online Education", has the background image, + # as well as the top-level promo video + "show_home_header": False, + # This controls whether the social sharing links of the course about page should be displayed + "course_about_show_social_links": True, + # This is the text on the course index page which is overlaid on top of the background image + "course_index_overlay_text": "Explore free courses from leading universities.", + # This is the logo that displays in the overlay on top of the backgroundimage + "course_index_overlay_logo_file": "/static/images/edx_bw.png", + # we can specify the email templatea here itself rather than referring to an on-disk + # template file. This helps to keep branding stuff out of the repo and we can manage it via + # configuration. Unfortunately, using this technique causes I18N issues, but Open edX wasn't + # natively supporting I18N on email on-disk templates, so this isn't a regression + "email_templates": { + "activation_email": { + "subject": "Your account for Open edX", + "body": + "Thank you for signing up for Open edX! To activate\n" + "your account, please copy and paste this address into your web\n" + "browser's address bar:\n" + "\n" + "https://${ site_domain }/activate/${ key }\n" + "\n" + "If you didn't request this, you don't need to do anything; you won't\n" + "receive any more email from us. Please do not reply to this e-mail;\n" + "if you require assistance, check the help section of the Open edX web site.\n" + }, + "confirm_email_change": { + "subject": "Request to change Open edX account e-mail\n", + "body": + "<%! from django.core.urlresolvers import reverse %>\n" + "This is to confirm that you changed the e-mail associated with\n" + "Open edX from ${old_email} to ${new_email}. If you\n" + "did not make this request, please contact us immediately. Contact\n" + "information is listed at:\n" + "\n" + "https://${ site_domain }${reverse('contact')}\n" + "\n" + "We keep a log of old e-mails, so if this request was unintentional, we\n" + "can investigate." + }, + "allowed_enroll": { + "subject": "You have been invited to register for ${course_name}", + "body": + "To finish your registration, please visit ${registration_url} and fill\n" + "out the registration form making sure to use ${email_address} in the E-mail field.\n" + "% if auto_enroll:\n" + "Once you have registered and activated your account, you will see\n" + "${course_name} listed on your dashboard.\n" + "% else:\n" + "Once you have registered and activated your account, visit ${course_about_url}\n" + "to join the course.\n" + "% endif\n" + "\n----\nThis email was automatically sent from ${site_name} to\n" + "${email_address}" + }, + "enrolled_enroll": { + "subject": "You have been enrolled in ${course_name}", + "body": + "Dear ${full_name}\n" + "\n" + "You have been enrolled in {course_name} at ${platform_name} by a member\n" + "of the course staff. The course should now appear on your ${site_name}\n" + "dashboard.\n" + "\n" + "To start accessing course materials, please visit ${course_url}" + "\n" + "\n" + "----\n" + "This email was automatically sent from ${site_name} to\n" + "${full_name}" + }, + "allowed_unenroll": { + "subject": "You have been un-enrolled from ${course_name}", + "body": + "\n" + "Dear Student,\n" + "\n" + "You have been un-enrolled from course ${course_name} by a member\n" + "of the course staff. Please disregard the invitation\n" + "previously sent.\n" + "\n" + "----\n" + "This email was automatically sent from ${site_name}\n" + "to ${email_address}" + }, + "enrolled_unenroll": { + "subject": "You have been un-enrolled from ${course_name}", + "body": + "\n" + "Dear ${full_name}\n" + "\n" + "You have been un-enrolled in ${course_name} at ${site_name} by a member\n" + "of the course staff. The course will no longer appear on your\n" + "${site_name} dashboard.\n" + "\n" + "Your other courses have not been affected.\n" + "\n" + "----\n" + "This email was automatically sent from ${site_name} to\n" + "${full_name}" + }, + "order_confirmation_email": { + "subject": "Order Payment Confirmation", + "body": + "Hi ${order.user.profile.name}\n" + "\n" + "Your payment was successful. You will see the charge below on your next credit or debit card statement.\n" + "The charge will show up on your statement under the company name ${settings.CC_MERCHANT_NAME}.\n" + "If you have billing questions, please contact ${settings.PAYMENT_SUPPORT_EMAIL}.\n" + "-The ${settings.PLATFORM_NAME} Team\n" + "\n" + "Your order number is: ${order.id}\n" + "\n" + "The items in your order are:\n" + "\n" + "Quantity - Description - Price\n" + "%for order_item in order_items:\n" + "${order_item.qty} - ${order_item.line_desc} - ${'$'' if order_item.currency == 'usd' else ''}${order_item.line_cost}\n" + "%endfor\n" + "\n" + "Total billed to credit/debit card: ${order.total_cost}${'$'' if order.currency == 'usd' else '')}\n" + "\n" + "% if has_billing_info:" + "${order.bill_to_cardtype} ${_('#:'')} ${order.bill_to_ccnum}" + "${order.bill_to_first} ${order.bill_to_last}" + "${order.bill_to_street1}" + "${order.bill_to_street2}" + "${order.bill_to_city}, ${order.bill_to_state} ${order.bill_to_postalcode}" + "${order.bill_to_country.upper()}" + "% endif" + "\n" + "%for order_item in order_items:\n" + "${order_item.additional_instruction_text}\n" + "%endfor\n" + } + }, + }, + "edge": { + # if set will render to different template in the index page + # if not set, then the default index page will be rendered + "university_profile_template": "university_profile/edge.html", + } +} diff --git a/lms/static/images/open_edX_logo.png b/lms/static/images/open_edX_logo.png new file mode 100644 index 000000000000..4573e9e0d6e9 Binary files /dev/null and b/lms/static/images/open_edX_logo.png differ diff --git a/lms/static/microsites/openedx/css/openedx.css b/lms/static/microsites/openedx/css/openedx.css new file mode 100644 index 000000000000..9828525211fd --- /dev/null +++ b/lms/static/microsites/openedx/css/openedx.css @@ -0,0 +1,7 @@ +.find-courses header.search, .university-profile header.search { + background-image: url("../../../images/homepage-bg.jpg"); +} + +.course-info header.course-profile { + background: url("../../../images/homepage-bg.jpg") repeat scroll 0 0 / cover #F5F5F5; +} \ No newline at end of file diff --git a/lms/templates/courseware/course_about.html b/lms/templates/courseware/course_about.html index 81e9e2507f2e..e54d306b0b51 100644 --- a/lms/templates/courseware/course_about.html +++ b/lms/templates/courseware/course_about.html @@ -11,15 +11,20 @@ cart_link = "" %> <%namespace name='static' file='../static_content.html'/> +<%! from microsite_configuration.middleware import MicrositeConfiguration %> <%inherit file="../main.html" /> <%block name="headextra"> - % if self.theme_enabled(): - <%include file="../theme-google-analytics.html" /> - % else: - <%include file="../google_analytics.html" /> - % endif + + <% + if self.theme_enabled(): + google_analytics_file = u'../' + MicrositeConfiguration.get_microsite_configuration_value('google_analytics_file', 'theme-google-analytics.html') + else: + google_analytics_file = '../google_analytics.html' + %> + + <%include file="${google_analytics_file}" /> <%block name="js_extra"> @@ -196,6 +201,7 @@

+ % if MicrositeConfiguration.get_microsite_configuration_value('course_about_show_social_links', True): + % endif
    diff --git a/lms/templates/courseware/courses.html b/lms/templates/courseware/courses.html index 1f1d96a0dffd..777605bbd756 100644 --- a/lms/templates/courseware/courses.html +++ b/lms/templates/courseware/courses.html @@ -4,8 +4,20 @@ <%namespace name='static' file='../static_content.html'/> <%block name="title">${_("Courses")} +<%! from microsite_configuration.middleware import MicrositeConfiguration %>
    + +<% + course_index_overlay_text = MicrositeConfiguration.get_microsite_configuration_value('course_index_overlay_text', _("Explore free courses from leading universities.")) + + # not sure why this is, but if I use static.url('images/edx_bw.png') then the HTML rendering + # of this template goes wonky + + logo_file = MicrositeConfiguration.get_microsite_configuration_value( + 'course_index_overlay_logo_file', settings.STATIC_URL + 'images/edx_bw.png') +%> + -
    ## Disable university partner logos and sites for non-edX sites diff --git a/lms/templates/main.html b/lms/templates/main.html index a2395db4ed39..3dd165e3c54c 100644 --- a/lms/templates/main.html +++ b/lms/templates/main.html @@ -1,4 +1,5 @@ <%! from django.utils.translation import ugettext as _ %> +<%! from microsite_configuration.middleware import MicrositeConfiguration %> <%namespace name='static' file='static_content.html'/> <%! from django.utils import html %> @@ -8,11 +9,26 @@ ## templates have access to these functions, and we can import these ## into non-inheriting templates via the %namespace tag. <%def name="theme_enabled()"> - <% return settings.FEATURES["USE_CUSTOM_THEME"] %> + <% + + theme_enabled = MicrositeConfiguration.get_microsite_configuration_value('USE_CUSTOM_THEME') or settings.FEATURES["USE_CUSTOM_THEME"] + return theme_enabled + + %> <%def name="stanford_theme_enabled()"> - <% return theme_enabled() and getattr(settings, "THEME_NAME") == "stanford" %> + <% + if not theme_enabled(): + return False + + theme_name = MicrositeConfiguration.get_microsite_configuration_value('THEME_NAME') + + if hasattr(settings, "THEME_NAME"): + theme_name = getattr(settings, "THEME_NAME") + + return theme_enabled and theme_name == "stanford" + %> @@ -54,8 +70,24 @@ <%static:js group='main_vendor'/> <%block name="headextra"/> - % if theme_enabled(): - <%include file="theme-head-extra.html" /> + +<% + footer_html = None + if theme_enabled(): + header_extra_file = MicrositeConfiguration.get_microsite_configuration_value('header_extra_file', 'theme-head-extra.html') + header_file = MicrositeConfiguration.get_microsite_configuration_value('header_file', 'navigation.html') + google_analytics_file = MicrositeConfiguration.get_microsite_configuration_value('google_analytics_file', 'theme-google-analytics.html') + footer_file = MicrositeConfiguration.get_microsite_configuration_value('footer_file', 'theme-footer.html') + footer_html = MicrositeConfiguration.get_microsite_configuration_value('footer_html') + else: + header_extra_file = None + header_file = "navigation.html" + google_analytics_file = "google_analytics.html" + footer_file = "footer.html" +%> + + % if header_extra_file: + <%include file="${header_extra_file}" /> % endif