diff --git a/lms/djangoapps/certificates/signals.py b/lms/djangoapps/certificates/signals.py index be2ae3f63963..5d3c38f30288 100644 --- a/lms/djangoapps/certificates/signals.py +++ b/lms/djangoapps/certificates/signals.py @@ -6,6 +6,7 @@ import logging import six +from django.conf import settings from django.db.models.signals import post_save from django.dispatch import receiver @@ -156,6 +157,12 @@ def fire_ungenerated_certificate_task(user, course_key, expected_verification_st CourseMode.NO_ID_PROFESSIONAL_MODE, CourseMode.MASTERS, ] + if settings.FEATURES.get('TAHOE_AUTO_GENERATE_HONOR_CERTS', False): + # Appsembler change: allow for Honor mode + allowed_enrollment_modes_list += [ + CourseMode.HONOR + ] + enrollment_mode, __ = CourseEnrollment.enrollment_mode_for_user(user, course_key) cert = GeneratedCertificate.certificate_for_student(user, course_key) diff --git a/lms/djangoapps/certificates/tests/test_signals.py b/lms/djangoapps/certificates/tests/test_signals.py index 87fcc6eb8245..f8c147f801a5 100644 --- a/lms/djangoapps/certificates/tests/test_signals.py +++ b/lms/djangoapps/certificates/tests/test_signals.py @@ -8,6 +8,8 @@ import mock import six +from django.conf import settings + from lms.djangoapps.certificates import api as certs_api from lms.djangoapps.certificates.models import ( CertificateGenerationConfiguration, @@ -367,9 +369,10 @@ def setUp(self): ('credit', True), ('masters', True), ('audit', False), - ('honor', False), + ('honor', True), # TAHOE_AUTO_GENERATE_HONOR_CERTS feature: allows for honor ) @ddt.unpack + @mock.patch.dict(settings.FEATURES, {'TAHOE_AUTO_GENERATE_HONOR_CERTS': True}) def test_fire_ungenerated_certificate_task_allowed_modes(self, enrollment_mode, should_create): """ Test that certificate generation task is fired for only modes that are diff --git a/lms/djangoapps/static_template_view/urls.py b/lms/djangoapps/static_template_view/urls.py index 819c4a0a1da5..052c50014608 100644 --- a/lms/djangoapps/static_template_view/urls.py +++ b/lms/djangoapps/static_template_view/urls.py @@ -16,6 +16,7 @@ url(r'^500$', views.render_500, name='static_template_view.views.render_500'), url(r'^blog$', views.render, {'template': 'blog.html'}, name="blog"), + url(r'^tracks$', views.render, {'template': 'tracks.html'}, name="tracks"), url(r'^contact$', views.render, {'template': 'contact.html'}, name="contact"), url(r'^donate$', views.render, {'template': 'donate.html'}, name="donate"), url(r'^faq$', views.render, {'template': 'faq.html'}, name="faq"), diff --git a/lms/envs/common.py b/lms/envs/common.py index 6cf18fdb9b1b..01a857758763 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1515,6 +1515,9 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring 'django.contrib.sites.middleware.CurrentSiteMiddleware', 'edx_rest_framework_extensions.auth.jwt.middleware.JwtAuthCookieMiddleware', + # Allows us to define redirects via Django admin + 'django_sites_extensions.middleware.RedirectMiddleware', + # Instead of SessionMiddleware, we use a more secure version # 'django.contrib.sessions.middleware.SessionMiddleware', 'openedx.core.djangoapps.safe_sessions.middleware.SafeSessionMiddleware', diff --git a/lms/templates/static_templates/tracks.html b/lms/templates/static_templates/tracks.html new file mode 100644 index 000000000000..9f99d8e0698f --- /dev/null +++ b/lms/templates/static_templates/tracks.html @@ -0,0 +1,16 @@ +<%page expression_filter="h"/> +<%! from django.utils.translation import ugettext as _ %> +<%inherit file="../main.html" /> + +<%block name="pagetitle">${_("Tracks")} + +
+
+

+ <%block name="pageheader">${page_header or _("Tracks")} +

+

+ <%block name="pagecontent">${page_content or _("This page left intentionally blank. Feel free to add your own content.")} +

+
+
diff --git a/openedx/core/djangoapps/appsembler/api/tests/factories.py b/openedx/core/djangoapps/appsembler/api/tests/factories.py index c4d597e2134a..5ff6df070a22 100644 --- a/openedx/core/djangoapps/appsembler/api/tests/factories.py +++ b/openedx/core/djangoapps/appsembler/api/tests/factories.py @@ -2,6 +2,8 @@ import datetime import factory +import factory.fuzzy + from openedx.core.djangoapps.content.course_overviews.models import ( CourseOverview, ) diff --git a/openedx/core/djangoapps/appsembler/settings/settings/common.py b/openedx/core/djangoapps/appsembler/settings/settings/common.py index 28df33d9e47b..273e8cc3fdfc 100644 --- a/openedx/core/djangoapps/appsembler/settings/settings/common.py +++ b/openedx/core/djangoapps/appsembler/settings/settings/common.py @@ -62,3 +62,6 @@ def plugin_settings(settings): settings.CUSTOM_DOMAINS_REDIRECT_CACHE_KEY_PREFIX = 'custom_domains_redirects' settings.COPY_SEGMENT_EVENT_PROPERTIES_TO_TOP_LEVEL = False + + # Appsembler allows generating honor certs + settings.FEATURES['TAHOE_AUTO_GENERATE_HONOR_CERTS'] = True diff --git a/openedx/core/djangoapps/appsembler/settings/settings/devstack_lms.py b/openedx/core/djangoapps/appsembler/settings/settings/devstack_lms.py index 1cbe64a300ae..79319d34cd69 100644 --- a/openedx/core/djangoapps/appsembler/settings/settings/devstack_lms.py +++ b/openedx/core/djangoapps/appsembler/settings/settings/devstack_lms.py @@ -28,4 +28,5 @@ def plugin_settings(settings): # This is used in the appsembler_sites.middleware.RedirectMiddleware to exclude certain paths # from the redirect mechanics. - settings.MAIN_SITE_REDIRECT_WHITELIST += ['/media/'] + if settings.APPSEMBLER_FEATURES.get("TAHOE_ENABLE_DOMAIN_REDIRECT_MIDDLEWARE", True): + settings.MAIN_SITE_REDIRECT_WHITELIST += ['/media/'] diff --git a/openedx/core/djangoapps/appsembler/settings/settings/production_lms.py b/openedx/core/djangoapps/appsembler/settings/settings/production_lms.py index cf85f8c2219a..711bce62b226 100644 --- a/openedx/core/djangoapps/appsembler/settings/settings/production_lms.py +++ b/openedx/core/djangoapps/appsembler/settings/settings/production_lms.py @@ -6,6 +6,10 @@ from openedx.core.djangoapps.appsembler.settings.settings import production_common +EDX_SITE_REDIRECT_MIDDLEWARE = "django_sites_extensions.middleware.RedirectMiddleware" +TAHOE_MARKETING_SITE_URL = "https://appsembler.com/tahoe" + + def _add_theme_static_dirs(settings): """ Appsembler Themes static files customizations. @@ -43,8 +47,39 @@ def plugin_settings(settings): This file, however, won't run in test environments. """ + settings.MIDDLEWARE += [ + # LmsCurrentOrganizationMiddleware needs to go before `TiersMiddleware` in aws_common.plugin_settings() + 'openedx.core.djangoapps.appsembler.sites.middleware.LmsCurrentOrganizationMiddleware', + ] + production_common.plugin_settings(settings) + if settings.APPSEMBLER_FEATURES.get("TAHOE_ENABLE_DOMAIN_REDIRECT_MIDDLEWARE", True): + redir_middleware_index = settings.MIDDLEWARE.index(EDX_SITE_REDIRECT_MIDDLEWARE) + settings.MIDDLEWARE.insert( + redir_middleware_index, # Insert after Django RedirectMiddleware + 'openedx.core.djangoapps.appsembler.sites.middleware.CustomDomainsRedirectMiddleware' + ) + settings.MIDDLEWARE.insert( + redir_middleware_index + 1, # Insert after CustomDomainsRedirectMiddleware + 'openedx.core.djangoapps.appsembler.sites.middleware.RedirectMiddleware' + ) + + settings.TAHOE_MAIN_SITE_REDIRECT_URL = settings.ENV_TOKENS.get( + 'TAHOE_MAIN_SITE_REDIRECT_URL', TAHOE_MARKETING_SITE_URL + ) + # This is used in the appsembler_sites.middleware.RedirectMiddleware to exclude certain paths + # from the redirect mechanics. + settings.MAIN_SITE_REDIRECT_WHITELIST = [ + 'api', + 'admin', + 'oauth', + 'status', + '/heartbeat', + '/accounts/manage_user_standing', + '/accounts/disable_account_ajax', + ] + settings.LMS_BASE = settings.ENV_TOKENS.get('LMS_BASE') settings.INSTALLED_APPS += ( @@ -97,23 +132,4 @@ def plugin_settings(settings): settings.ACCESS_CONTROL_BACKENDS = settings.ENV_TOKENS.get('ACCESS_CONTROL_BACKENDS', {}) settings.LMS_SEGMENT_SITE = settings.AUTH_TOKENS.get('SEGMENT_SITE') - _after_site_mdlwr = settings.MIDDLEWARE.index('django.contrib.sites.middleware.CurrentSiteMiddleware') + 1 - settings.MIDDLEWARE = settings.MIDDLEWARE[:_after_site_mdlwr] + [ - # Allows us to define redirects via Django admin - 'openedx.core.djangoapps.appsembler.sites.middleware.CustomDomainsRedirectMiddleware', - 'openedx.core.djangoapps.appsembler.sites.middleware.RedirectMiddleware', - ] + settings.MIDDLEWARE[_after_site_mdlwr:] - - # This is used in the appsembler_sites.middleware.RedirectMiddleware to exclude certain paths - # from the redirect mechanics. - settings.MAIN_SITE_REDIRECT_WHITELIST = [ - 'api', - 'admin', - 'oauth', - 'status', - '/heartbeat', - '/accounts/manage_user_standing', - '/accounts/disable_account_ajax', - ] - _add_theme_static_dirs(settings) diff --git a/openedx/core/djangoapps/appsembler/settings/tests/test_settings.py b/openedx/core/djangoapps/appsembler/settings/tests/test_settings.py index da96375b10d2..90fa88b0475f 100644 --- a/openedx/core/djangoapps/appsembler/settings/tests/test_settings.py +++ b/openedx/core/djangoapps/appsembler/settings/tests/test_settings.py @@ -29,6 +29,7 @@ def get_faked_settings(): settings.APPSEMBLER_FEATURES = {} settings.MIDDLEWARE = [ 'django.contrib.sites.middleware.CurrentSiteMiddleware', + 'django_sites_extensions.middleware.RedirectMiddleware', ] settings.STATICFILES_DIRS = [] settings.CACHES = {} diff --git a/openedx/core/djangoapps/appsembler/sites/middleware.py b/openedx/core/djangoapps/appsembler/sites/middleware.py index f27b232458e0..aa458b50f373 100644 --- a/openedx/core/djangoapps/appsembler/sites/middleware.py +++ b/openedx/core/djangoapps/appsembler/sites/middleware.py @@ -1,13 +1,15 @@ +import beeline +import logging + from django.conf import settings -from django.core.cache import cache, caches -from django.contrib.redirects.models import Redirect +from django.core.cache import caches from django.shortcuts import redirect from django.utils.deprecation import MiddlewareMixin from openedx.core.djangoapps.appsembler.sites.models import AlternativeDomain +from openedx.core.djangoapps.appsembler.sites.utils import get_current_organization + -import beeline -import logging log = logging.getLogger(__name__) @@ -38,8 +40,7 @@ def process_request(self, request): class RedirectMiddleware(MiddlewareMixin): """ - Redirects requests for URLs persisted using the django.contrib.redirects.models.Redirect model. - With the exception of the main site. + Redirects requests for main site to Tahoe marketing page, except whitelisted. """ def process_request(self, request): """ @@ -51,17 +52,23 @@ def process_request(self, request): beeline.add_trace_field("site_id", site.id) in_whitelist = any([p in request.path for p in settings.MAIN_SITE_REDIRECT_WHITELIST]) if (site.id == settings.SITE_ID) and not in_whitelist: - return redirect("https://appsembler.com/tahoe/") + return redirect(settings.TAHOE_MAIN_SITE_REDIRECT_URL) except Exception: # I'm not entirely sure this middleware get's called only in LMS or in other apps as well. # Soooo just in case beeline.add_trace_field("redirect_middleware_exception", True) pass - cache_key = '{prefix}-{site}'.format(prefix=settings.REDIRECT_CACHE_KEY_PREFIX, site=site.domain) - redirects = cache.get(cache_key) - if redirects is None: - redirects = {redirect.old_path: redirect.new_path for redirect in Redirect.objects.filter(site=site)} - cache.set(cache_key, redirects, settings.REDIRECT_CACHE_TIMEOUT) - redirect_to = redirects.get(request.path) - if redirect_to: - return redirect(redirect_to, permanent=True) + + +class LmsCurrentOrganizationMiddleware(object): + """ + Get the current middleware for the LMS. + + This middleware replaces the default `organizations.OrganizationMiddleware` to + use a better get_current_organization() helper. + """ + def process_request(self, request): + # Note: This does _not_ support multiple organizations per user. + organization = get_current_organization(failure_return_none=True) + beeline.add_trace_field('session_current_organization', organization) + request.session['organization'] = organization diff --git a/openedx/core/djangoapps/appsembler/sites/serializers.py b/openedx/core/djangoapps/appsembler/sites/serializers.py index 1d3acae357cb..dbbe13c946e0 100644 --- a/openedx/core/djangoapps/appsembler/sites/serializers.py +++ b/openedx/core/djangoapps/appsembler/sites/serializers.py @@ -1,3 +1,4 @@ +import beeline from django.conf import settings from django.contrib.sites.models import Site from django.db import transaction @@ -34,7 +35,9 @@ class Meta: model = SiteConfiguration fields = ('id', 'values', 'sassVariables', 'pageElements') + @beeline.traced(name="SiteConfigurationSerializer.update") def update(self, instance, validated_data): + beeline.add_context_field("validated_data", validated_data) object = super(SiteConfigurationSerializer, self).update(instance, validated_data) return object @@ -51,13 +54,17 @@ class Meta: model = AlternativeDomain fields = ('id', 'site', 'domain') + @beeline.traced(name="AlternativeDomainSerializer.create") def create(self, validated_data): """ Allow only one alternative domain per Site model. """ + beeline.add_context_field("validated_data", validated_data) domain, created = AlternativeDomain.objects.get_or_create( site=validated_data.get('site', None), defaults={'domain': validated_data.get('domain', None)}) + beeline.add_context_field("domain", domain) + beeline.add_context_field("created", created) if not created: domain.domain = validated_data.get('domain', None) domain.save() @@ -73,11 +80,14 @@ class Meta: model = Site fields = ('id', 'name', 'domain', 'configuration', 'alternativeDomain', 'customDomainStatus') + @beeline.traced(name="SiteSerializer.create") def create(self, validated_data): + beeline.add_context_field("validated_data", validated_data) site = super(SiteSerializer, self).create(validated_data) _organization, site, _user = bootstrap_site(site) return site + @beeline.traced(name="SiteSerializer.custom_domain_status") def custom_domain_status(self, obj): if not hasattr(obj, 'alternative_domain'): return 'inactive' @@ -89,7 +99,9 @@ class Meta: model = Organization fields = ('id', 'name', 'short_name', 'edx_uuid') + @beeline.traced(name="OrganizationSerializer.create") def create(self, validated_data): + beeline.add_context_field("validated_data", validated_data) return organizations_api.add_organization(**validated_data) @@ -101,7 +113,9 @@ class RegistrationSerializer(serializers.Serializer): password = serializers.CharField(required=False) initial_values = serializers.DictField(required=False) + @beeline.traced(name="RegistrationSerializer.create") def create(self, validated_data): + beeline.add_context_field('validated_data', validated_data) site_data = validated_data.pop('site') site = Site.objects.create(**site_data) organization_data = validated_data.pop('organization') @@ -143,6 +157,8 @@ def create(self, validated_data): # clone course if settings.FEATURES.get("APPSEMBLER_IMPORT_DEFAULT_COURSE_ON_SITE_CREATION", False): + beeline.add_context_field("default_course_on_site_creation_flag", True) + def import_task_on_commit(): """ Run the import task after the commit to avoid Organization.DoesNotExist error on the Celery. diff --git a/openedx/core/djangoapps/appsembler/sites/tests/test_middlewares.py b/openedx/core/djangoapps/appsembler/sites/tests/test_middlewares.py new file mode 100644 index 000000000000..ac86ce4e60ee --- /dev/null +++ b/openedx/core/djangoapps/appsembler/sites/tests/test_middlewares.py @@ -0,0 +1,66 @@ +""" +Tests for the sites.middlewares module. +""" + +from mock import patch, Mock +from django.test import TestCase, override_settings +from django.test.client import RequestFactory + +from openedx.core.djangoapps.appsembler.sites.middleware import LmsCurrentOrganizationMiddleware, RedirectMiddleware +from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory + + +@patch('openedx.core.djangoapps.appsembler.sites.middleware.get_current_organization') +class LmsCurrentOrganizationMiddlewareTests(TestCase): + def test_with_organization(self, get_current_organization): + middleware = LmsCurrentOrganizationMiddleware() + request = Mock(session={}) + fake_org = Mock() + get_current_organization.return_value = fake_org + + middleware.process_request(request) + assert request.session['organization'] is fake_org + + def test_with_no_organization(self, get_current_organization): + middleware = LmsCurrentOrganizationMiddleware() + request = Mock(session={}) + get_current_organization.return_value = None + + middleware.process_request(request) + assert request.session['organization'] is None + + +@override_settings(TAHOE_MAIN_SITE_REDIRECT_URL='https://foo.bar') +@override_settings(MAIN_SITE_REDIRECT_WHITELIST=['/baz']) +class RedirectMiddlewareTests(TestCase): + def setUp(self): + super(RedirectMiddlewareTests, self).setUp() + self.default_site = SiteFactory.create() + self.other_site = SiteFactory.create() + patcher = patch("django.conf.settings.SITE_ID", self.default_site.id) + patcher.start() + self.addCleanup(patcher.stop) + + def test_redirects_default_site(self): + request = RequestFactory().get('/path', HTTP_HOST=self.default_site.domain) + request.site = self.default_site + processed = RedirectMiddleware().process_request(request) + self.assertEquals(processed.url, 'https://foo.bar') # middleware drops the path + + def test_with_no_site_found(self): + request = RequestFactory().get('/') + request.site = None + processed = RedirectMiddleware().process_request(request) + self.assertIsNone(processed) + + def test_no_redirect_other_site(self): + request = RequestFactory().get('/', HTTP_HOST=self.other_site.domain) + request.site = self.other_site + processed = RedirectMiddleware().process_request(request) + self.assertIsNone(processed) + + def test_url_in_whitelist(self): + request = RequestFactory().get('/baz', HTTP_HOST=self.default_site.domain) + request.site = self.default_site + processed = RedirectMiddleware().process_request(request) + self.assertIsNone(processed) diff --git a/openedx/core/djangoapps/appsembler/sites/tests/test_utils.py b/openedx/core/djangoapps/appsembler/sites/tests/test_utils.py index 7d5141d1323e..46f03aae80ac 100644 --- a/openedx/core/djangoapps/appsembler/sites/tests/test_utils.py +++ b/openedx/core/djangoapps/appsembler/sites/tests/test_utils.py @@ -1,6 +1,22 @@ +import unittest + +from django.db.models import QuerySet +from mock import patch + +from django.core.exceptions import ImproperlyConfigured, MultipleObjectsReturned from django.test import TestCase +from django.test.client import RequestFactory + +from openedx.core.djangoapps.appsembler.api.tests.factories import OrganizationFactory +from openedx.core.djangoapps.appsembler.sites.utils import ( + get_current_organization, + get_initial_page_elements, + get_active_sites, + get_lms_link_from_course_key +) +from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory -from openedx.core.djangoapps.appsembler.sites.utils import get_initial_page_elements +from organizations.models import Organization class JSONMigrationUtilsTestCase(TestCase): @@ -14,3 +30,134 @@ def test_initial_page_elements(self): self.assertEqual(element['options']['text-content'], { 'en': 'Welcome to your Tahoe trial LMS site!', }) + + +class ActiveSitesTestCase(TestCase): + def setUp(self): + super(ActiveSitesTestCase, self).setUp() + self.siteFoo = SiteFactory.create(domain='foo.dev', name='foo.dev') + self.siteBar = SiteFactory.create(domain='bar.dev', name='bar.dev') + self.organizationA = OrganizationFactory(sites=[self.siteFoo]) + self.organizationB = OrganizationFactory(sites=[self.siteBar]) + + def test_get_active_sites(self): + """ + Basic test for results. + """ + with patch('openedx.core.djangoapps.appsembler.sites.utils.get_active_organizations') as mocked: + mocked.return_value = [self.organizationA, self.organizationB] + active_sites = get_active_sites() + assert len(active_sites) == 2 + assert active_sites[0].domain == 'bar.dev' + assert active_sites[1].domain == 'foo.dev' + + def test_get_active_sites_queryset(self): + """ + Should return QuerySet to work well with ViewSets and other plugins. + """ + with patch('openedx.core.djangoapps.appsembler.sites.utils.get_active_organizations') as mocked: + mocked.return_value = [self.organizationA, self.organizationB] + active_sites = get_active_sites() + assert type(active_sites) == QuerySet + + def test_get_active_sites_ordering(self): + """ + Result ordering is useful for tests but it's worth testing it itself. + """ + with patch('openedx.core.djangoapps.appsembler.sites.utils.get_active_organizations') as mocked: + mocked.return_value = [self.organizationA, self.organizationB] + active_sites = get_active_sites('-domain') + assert active_sites[0].domain == 'foo.dev' + + +class OrganizationByRequestTestCase(TestCase): + def setUp(self): + super(OrganizationByRequestTestCase, self).setUp() + self.siteFoo = SiteFactory.create(domain='foo.dev', name='foo.dev') + self.siteBar = SiteFactory.create(domain='bar.dev', name='bar.dev') + self.siteBaz = SiteFactory.create(domain='baz.dev', name='baz.dev') + self.organizationA = OrganizationFactory(sites=[self.siteFoo]) + self.organizationB = OrganizationFactory(sites=[self.siteFoo]) + self.organizationC = OrganizationFactory(sites=[self.siteBar]) + self.request = RequestFactory().post('dummy_url') + self.request.session = {} + for patch_req in ( + 'openedx.core.djangoapps.appsembler.sites.utils.get_current_request', + 'openedx.core.djangoapps.theming.helpers.get_current_request' + ): + patcher = patch(patch_req) + patched_req = patcher.start() + patched_req.return_value = self.request + self.addCleanup(patcher.stop) + + @unittest.skip + def test_amc_admin_user_no_org_in_request(self): + # TODO: would be good to test + pass + + @patch.dict('django.conf.settings.FEATURES', {'TAHOE_ENABLE_MULTI_ORGS_PER_SITE': False}) + def test_single_organization_multiorg_feature_off(self): + self.request.site = self.siteBar + current_org = get_current_organization() + self.assertEqual(current_org, self.organizationC) + + @patch.dict('django.conf.settings.FEATURES', {'TAHOE_ENABLE_MULTI_ORGS_PER_SITE': False}) + def test_multiple_organization_multiorg_feature_off(self): + self.request.site = self.siteFoo + # fail raising exception if more than one org found for site when feature not enabled + with self.assertRaises(MultipleObjectsReturned): + get_current_organization() + + @patch.dict('django.conf.settings.FEATURES', {'TAHOE_ENABLE_MULTI_ORGS_PER_SITE': True}) + def test_multiple_organizations_multiorg_feature_on(self): + self.request.site = self.siteFoo + # return one org from Site's org relations + current_org = get_current_organization() + self.assertIn(current_org, (self.organizationA, self.organizationB)) + + def test_no_org_for_site(self): + self.request.site = self.siteBaz + with self.assertRaises(Organization.DoesNotExist): + get_current_organization() + + @patch.dict('django.conf.settings.FEATURES', { + 'TAHOE_ENABLE_MULTI_ORGS_PER_SITE': True, + 'APPSEMBLER_MULTI_TENANT_EMAILS': True + }) + def test_raises_if_multiorg_feature_and_multitenant_email_feature_on(self): + self.request.site = self.siteFoo + with self.assertRaises(ImproperlyConfigured): + get_current_organization() + + +class LMSLinkByCourseOrgTestCase(TestCase): + """ + Exercise getting the appropriate LMS Link for Studio "View in LMS" + based on the organization value set on the course that is being viewed. + (note we don't test with custom domains since that is handled by middleware) + """ + def setUp(self): + super(LMSLinkByCourseOrgTestCase, self).setUp() + self.siteFoo = SiteFactory.create(domain='foo.dev', name='foo.dev') + self.courseKey = "course-v1:org+course+run" + self.base_lms_url = "lms_base.domain" + + @patch('openedx.core.djangoapps.appsembler.api.sites.get_site_for_course') + def test_lms_link_happy_path(self, mocked_get_site_for_course): + mocked_get_site_for_course.return_value = self.siteFoo + url = get_lms_link_from_course_key(self.base_lms_url, self.courseKey) + self.assertEqual(url, "foo.dev") + + @patch('openedx.core.djangoapps.appsembler.api.sites.get_site_for_course') + def test_lms_link_no_site_matching_course(self, mocked_get_site_for_course): + mocked_get_site_for_course.return_value = None + url = get_lms_link_from_course_key(self.base_lms_url, self.courseKey) + self.assertEqual(url, self.base_lms_url) + + @patch.dict('django.conf.settings.FEATURES', { + 'PREVIEW_LMS_BASE': 'preview.lms_base.domain' + }) + def test_lms_link_for_preview_always_return_preview_domain(self): + preview_url = "preview.lms_base.domain" + url = get_lms_link_from_course_key(preview_url, self.courseKey) + self.assertEqual(url, preview_url) diff --git a/openedx/core/djangoapps/appsembler/sites/utils.py b/openedx/core/djangoapps/appsembler/sites/utils.py index 341b4d8697e2..cfb3f9132947 100644 --- a/openedx/core/djangoapps/appsembler/sites/utils.py +++ b/openedx/core/djangoapps/appsembler/sites/utils.py @@ -15,6 +15,7 @@ from django.conf import settings from django.contrib.auth.models import User from django.contrib.sites.models import Site +from django.core.exceptions import ImproperlyConfigured from django.db.models.query import Q from oauth2_provider.models import AccessToken, RefreshToken, Application @@ -40,12 +41,12 @@ def get_lms_link_from_course_key(base_lms_url, course_key): """ beeline.add_context_field("base_lms_url", base_lms_url) beeline.add_context_field("course_key", course_key) - try: - site_domain = Site.objects.get(name=course_key.org).domain - except Site.DoesNotExist: - site_domain = "{}.{}".format(course_key.org, base_lms_url) - - return site_domain + # avoid circular import + from openedx.core.djangoapps.appsembler.api.sites import get_site_for_course + course_site = get_site_for_course(course_key) + if course_site: + return course_site.domain + return base_lms_url @beeline.traced(name="get_site_by_organization") @@ -57,9 +58,9 @@ def get_site_by_organization(org): return org.sites.all()[0] -def get_active_organizations(): +def _get_active_tiers_uuids(): """ - Get active organizations based on Tiers information. + Get active Tier organiation UUIDs from the Tiers (AMC Postgres) database. Note: This mostly a hack that's needed for improving the performance of batch operations by excluding dead sites. @@ -68,20 +69,33 @@ def get_active_organizations(): """ from tiers.models import Tier # This queries the AMC Postgres database - active_tiers = Tier.objects.filter( + active_tiers_uuids = Tier.objects.filter( Q(tier_enforcement_exempt=True) | Q(tier_expires_at__gte=timezone.now()) ).annotate( organization_edx_uuid=F('organization__edx_uuid') ).values_list('organization_edx_uuid', flat=True) + return active_tiers_uuids + + +def get_active_organizations(): + """ + Get active organizations based on Tiers information. + + Note: This mostly a hack that's needed for improving the performance of + batch operations by excluding dead sites. + + TODO: This helper should live in a future Tahoe Sites package. + """ + active_tiers_uuids = _get_active_tiers_uuids() # Now back to the LMS MySQL database return Organization.objects.filter( - edx_uuid__in=[str(edx_uuid) for edx_uuid in active_tiers], + edx_uuid__in=[str(edx_uuid) for edx_uuid in active_tiers_uuids], ) -def get_active_sites(): +def get_active_sites(order_by='domain'): """ Get active sites based on Tiers information. @@ -90,10 +104,9 @@ def get_active_sites(): TODO: This helper should live in a future Tahoe Sites package. """ - sites = [] - for organization in get_active_organizations(): - sites.extend(organization.sites.all()) - return sites + return Site.objects.filter( + organizations__in=get_active_organizations() + ).order_by(order_by) @beeline.traced(name="get_amc_oauth_app") @@ -284,10 +297,22 @@ def _get_current_organization(failure_return_none=False): ) else: try: - # TODO: Using `get` is expected to fail when multiple-orgs found for a site. - # Maybe catch MultipleObjectsReturned? - current_org = current_site.organizations.get() - except Organization.DoesNotExist: + if settings.FEATURES.get('TAHOE_ENABLE_MULTI_ORGS_PER_SITE', False): + if settings.FEATURES.get('APPSEMBLER_MULTI_TENANT_EMAILS', False): + raise ImproperlyConfigured( + 'TAHOE_ENABLE_MULTI_ORGS_PER_SITE and ' + 'APPSEMBLER_MULTI_TENANT_EMAILS are incompatible as ' + 'we are not able to determine the exact Org when more than one ' + 'is associated with a Site.') + current_org = current_site.organizations.first() + if not current_org: + raise Organization.DoesNotExist( + 'TAHOE_ENABLE_MULTI_ORGS_PER_SITE: Could not find current ' + 'organization for site `{}`'.format(repr(current_site)) + ) + else: + current_org = current_site.organizations.get() + except (Organization.DoesNotExist, ImproperlyConfigured): if not failure_return_none: raise # Re-raise the exception else: @@ -448,10 +473,13 @@ def json_to_sass(json_input): def bootstrap_site(site, org_data=None, username=None): from openedx.core.djangoapps.site_configuration.models import SiteConfiguration organization_slug = org_data.get('name') + beeline.add_context_field("org_data", org_data) + beeline.add_context_field("username", username) # don't use create because we need to call save() to set some values automatically site_config = SiteConfiguration(site=site, enabled=True) site_config.save() site.configuration_id = site_config.id + beeline.add_context_field("site_config_id", site_config.id) # temp workarounds while old staging is still up and running if organization_slug: organization_data = org_api.add_organization({ diff --git a/requirements/edx/appsembler.txt b/requirements/edx/appsembler.txt index ac45e8170e65..aa26f94b956a 100644 --- a/requirements/edx/appsembler.txt +++ b/requirements/edx/appsembler.txt @@ -2,7 +2,7 @@ django-extensions==3.1.0 python-intercom==3.1.0 raven==6.10.0 django-anymail==5.0 -django-tiers==0.2.2 +django-tiers==0.2.3 dj-database-url==0.5.0 psycopg2-binary==2.8.3 django-hijack==2.1.10