Merge pull request #15159 from edx/robrap/LEARNER-881-course-waffle

LEARNER-881: Per-course rollout configuration model
This commit is contained in:
Robert Raposa
2017-06-01 16:37:57 -04:00
committed by GitHub
33 changed files with 812 additions and 211 deletions

View File

@@ -1010,6 +1010,9 @@ INSTALLED_APPS = (
# Customized celery tasks, including persisting failed tasks so they can # Customized celery tasks, including persisting failed tasks so they can
# be retried # be retried
'celery_utils', 'celery_utils',
# Waffle related utilities
'openedx.core.djangoapps.waffle_utils',
) )

View File

@@ -2267,7 +2267,7 @@ def auto_auth(request):
elif course_id: elif course_id:
# Redirect to the course homepage (in LMS) or outline page (in Studio) # Redirect to the course homepage (in LMS) or outline page (in Studio)
try: try:
redirect_url = reverse(course_home_url_name(request), kwargs={'course_id': course_id}) redirect_url = reverse(course_home_url_name(course_key), kwargs={'course_id': course_id})
except NoReverseMatch: except NoReverseMatch:
redirect_url = reverse('course_handler', kwargs={'course_key_string': course_id}) redirect_url = reverse('course_handler', kwargs={'course_key_string': course_id})
else: else:

View File

@@ -231,18 +231,18 @@ class TestFieldOverrideMongoPerformance(FieldOverridePerformanceTestCase):
# # of sql queries to default, # # of sql queries to default,
# # of mongo queries, # # of mongo queries,
# ) # )
('no_overrides', 1, True, False): (24, 1), ('no_overrides', 1, True, False): (25, 1),
('no_overrides', 2, True, False): (24, 1), ('no_overrides', 2, True, False): (25, 1),
('no_overrides', 3, True, False): (24, 1), ('no_overrides', 3, True, False): (25, 1),
('ccx', 1, True, False): (24, 1), ('ccx', 1, True, False): (25, 1),
('ccx', 2, True, False): (24, 1), ('ccx', 2, True, False): (25, 1),
('ccx', 3, True, False): (24, 1), ('ccx', 3, True, False): (25, 1),
('no_overrides', 1, False, False): (24, 1), ('no_overrides', 1, False, False): (25, 1),
('no_overrides', 2, False, False): (24, 1), ('no_overrides', 2, False, False): (25, 1),
('no_overrides', 3, False, False): (24, 1), ('no_overrides', 3, False, False): (25, 1),
('ccx', 1, False, False): (24, 1), ('ccx', 1, False, False): (25, 1),
('ccx', 2, False, False): (24, 1), ('ccx', 2, False, False): (25, 1),
('ccx', 3, False, False): (24, 1), ('ccx', 3, False, False): (25, 1),
} }
@@ -254,19 +254,19 @@ class TestFieldOverrideSplitPerformance(FieldOverridePerformanceTestCase):
__test__ = True __test__ = True
TEST_DATA = { TEST_DATA = {
('no_overrides', 1, True, False): (24, 3), ('no_overrides', 1, True, False): (25, 3),
('no_overrides', 2, True, False): (24, 3), ('no_overrides', 2, True, False): (25, 3),
('no_overrides', 3, True, False): (24, 3), ('no_overrides', 3, True, False): (25, 3),
('ccx', 1, True, False): (24, 3), ('ccx', 1, True, False): (25, 3),
('ccx', 2, True, False): (24, 3), ('ccx', 2, True, False): (25, 3),
('ccx', 3, True, False): (24, 3), ('ccx', 3, True, False): (25, 3),
('ccx', 1, True, True): (25, 3), ('ccx', 1, True, True): (26, 3),
('ccx', 2, True, True): (25, 3), ('ccx', 2, True, True): (26, 3),
('ccx', 3, True, True): (25, 3), ('ccx', 3, True, True): (26, 3),
('no_overrides', 1, False, False): (24, 3), ('no_overrides', 1, False, False): (25, 3),
('no_overrides', 2, False, False): (24, 3), ('no_overrides', 2, False, False): (25, 3),
('no_overrides', 3, False, False): (24, 3), ('no_overrides', 3, False, False): (25, 3),
('ccx', 1, False, False): (24, 3), ('ccx', 1, False, False): (25, 3),
('ccx', 2, False, False): (24, 3), ('ccx', 2, False, False): (25, 3),
('ccx', 3, False, False): (24, 3), ('ccx', 3, False, False): (25, 3),
} }

View File

@@ -2,15 +2,14 @@
This module is essentially a broker to xmodule/tabs.py -- it was originally introduced to This module is essentially a broker to xmodule/tabs.py -- it was originally introduced to
perform some LMS-specific tab display gymnastics for the Entrance Exams feature perform some LMS-specific tab display gymnastics for the Entrance Exams feature
""" """
import waffle
from django.conf import settings from django.conf import settings
from django.utils.translation import ugettext as _, ugettext_noop from django.utils.translation import ugettext as _, ugettext_noop
from courseware.access import has_access from courseware.access import has_access
from courseware.entrance_exams import user_can_skip_entrance_exam from courseware.entrance_exams import user_can_skip_entrance_exam
from openedx.core.lib.course_tabs import CourseTabPluginManager from openedx.core.lib.course_tabs import CourseTabPluginManager
from openedx.features.course_experience import default_course_url_name, UNIFIED_COURSE_EXPERIENCE_FLAG from openedx.features.course_experience import default_course_url_name
from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG
from request_cache.middleware import RequestCache from request_cache.middleware import RequestCache
from student.models import CourseEnrollment from student.models import CourseEnrollment
from xmodule.tabs import CourseTab, CourseTabList, key_checker, link_reverse_func from xmodule.tabs import CourseTab, CourseTabList, key_checker, link_reverse_func
@@ -66,8 +65,7 @@ class CourseInfoTab(CourseTab):
""" """
The "Home" tab is not shown for the new unified course experience. The "Home" tab is not shown for the new unified course experience.
""" """
request = RequestCache.get_current_request() return not UNIFIED_COURSE_TAB_FLAG.is_enabled(course.id)
return not waffle.flag_is_active(request, UNIFIED_COURSE_EXPERIENCE_FLAG)
class SyllabusTab(EnrolledTab): class SyllabusTab(EnrolledTab):

View File

@@ -7,8 +7,6 @@ from django.core.urlresolvers import reverse
from freezegun import freeze_time from freezegun import freeze_time
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
from pytz import utc from pytz import utc
from waffle.testutils import override_flag
from openedx.features.course_experience import UNIFIED_COURSE_EXPERIENCE_FLAG
from commerce.models import CommerceConfiguration from commerce.models import CommerceConfiguration
from course_modes.tests.factories import CourseModeFactory from course_modes.tests.factories import CourseModeFactory
@@ -24,6 +22,8 @@ from courseware.date_summary import (
) )
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
from openedx.core.djangoapps.user_api.preferences.api import set_user_preference from openedx.core.djangoapps.user_api.preferences.api import set_user_preference
from openedx.core.djangoapps.waffle_utils.testutils import override_waffle_flag
from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG
from student.tests.factories import CourseEnrollmentFactory, UserFactory from student.tests.factories import CourseEnrollmentFactory, UserFactory
from lms.djangoapps.verify_student.models import VerificationDeadline from lms.djangoapps.verify_student.models import VerificationDeadline
from lms.djangoapps.verify_student.tests.factories import SoftwareSecurePhotoVerificationFactory from lms.djangoapps.verify_student.tests.factories import SoftwareSecurePhotoVerificationFactory
@@ -194,7 +194,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
'info', 'info',
'openedx.course_experience.course_home', 'openedx.course_experience.course_home',
) )
@override_flag(UNIFIED_COURSE_EXPERIENCE_FLAG, active=True) @override_waffle_flag(UNIFIED_COURSE_TAB_FLAG, active=True)
def test_todays_date_no_timezone(self, url_name): def test_todays_date_no_timezone(self, url_name):
with freeze_time('2015-01-02'): with freeze_time('2015-01-02'):
self.setup_course_and_user() self.setup_course_and_user()
@@ -218,7 +218,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
'info', 'info',
'openedx.course_experience.course_home', 'openedx.course_experience.course_home',
) )
@override_flag(UNIFIED_COURSE_EXPERIENCE_FLAG, active=True) @override_waffle_flag(UNIFIED_COURSE_TAB_FLAG, active=True)
def test_todays_date_timezone(self, url_name): def test_todays_date_timezone(self, url_name):
with freeze_time('2015-01-02'): with freeze_time('2015-01-02'):
self.setup_course_and_user() self.setup_course_and_user()
@@ -249,7 +249,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
'info', 'info',
'openedx.course_experience.course_home', 'openedx.course_experience.course_home',
) )
@override_flag(UNIFIED_COURSE_EXPERIENCE_FLAG, active=True) @override_waffle_flag(UNIFIED_COURSE_TAB_FLAG, active=True)
def test_start_date_render(self, url_name): def test_start_date_render(self, url_name):
with freeze_time('2015-01-02'): with freeze_time('2015-01-02'):
self.setup_course_and_user() self.setup_course_and_user()
@@ -267,7 +267,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
'info', 'info',
'openedx.course_experience.course_home', 'openedx.course_experience.course_home',
) )
@override_flag(UNIFIED_COURSE_EXPERIENCE_FLAG, active=True) @override_waffle_flag(UNIFIED_COURSE_TAB_FLAG, active=True)
def test_start_date_render_time_zone(self, url_name): def test_start_date_render_time_zone(self, url_name):
with freeze_time('2015-01-02'): with freeze_time('2015-01-02'):
self.setup_course_and_user() self.setup_course_and_user()

View File

@@ -2,8 +2,6 @@
Test cases for tabs. Test cases for tabs.
""" """
from waffle.testutils import override_flag
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.http import Http404 from django.http import Http404
from mock import MagicMock, Mock, patch from mock import MagicMock, Mock, patch
@@ -18,7 +16,8 @@ from courseware.tests.helpers import LoginEnrollmentTestCase
from courseware.tests.factories import InstructorFactory, StaffFactory from courseware.tests.factories import InstructorFactory, StaffFactory
from courseware.views.views import get_static_tab_fragment, StaticCourseTabView from courseware.views.views import get_static_tab_fragment, StaticCourseTabView
from openedx.core.djangolib.testing.utils import get_mock_request from openedx.core.djangolib.testing.utils import get_mock_request
from openedx.features.course_experience import UNIFIED_COURSE_EXPERIENCE_FLAG from openedx.core.djangoapps.waffle_utils.testutils import override_waffle_flag
from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG
from student.models import CourseEnrollment from student.models import CourseEnrollment
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
from util.milestones_helpers import ( from util.milestones_helpers import (
@@ -776,12 +775,13 @@ class CourseInfoTabTestCase(TabTestCase):
self.user = self.create_mock_user() self.user = self.create_mock_user()
self.request = get_mock_request(self.user) self.request = get_mock_request(self.user)
@override_waffle_flag(UNIFIED_COURSE_TAB_FLAG, active=False)
def test_default_tab(self): def test_default_tab(self):
# Verify that the course info tab is the first tab # Verify that the course info tab is the first tab
tabs = get_course_tab_list(self.request, self.course) tabs = get_course_tab_list(self.request, self.course)
self.assertEqual(tabs[0].type, 'course_info') self.assertEqual(tabs[0].type, 'course_info')
@override_flag(UNIFIED_COURSE_EXPERIENCE_FLAG, active=True) @override_waffle_flag(UNIFIED_COURSE_TAB_FLAG, active=True)
def test_default_tab_for_new_course_experience(self): def test_default_tab_for_new_course_experience(self):
# Verify that the unified course experience hides the course info tab # Verify that the unified course experience hides the course info tab
tabs = get_course_tab_list(self.request, self.course) tabs = get_course_tab_list(self.request, self.course)

View File

@@ -210,8 +210,8 @@ class IndexQueryTestCase(ModuleStoreTestCase):
NUM_PROBLEMS = 20 NUM_PROBLEMS = 20
@ddt.data( @ddt.data(
(ModuleStoreEnum.Type.mongo, 10, 142), (ModuleStoreEnum.Type.mongo, 10, 143),
(ModuleStoreEnum.Type.split, 4, 142), (ModuleStoreEnum.Type.split, 4, 143),
) )
@ddt.unpack @ddt.unpack
def test_index_query_counts(self, store_type, expected_mongo_query_count, expected_mysql_query_count): def test_index_query_counts(self, store_type, expected_mongo_query_count, expected_mysql_query_count):
@@ -1420,12 +1420,12 @@ class ProgressPageTests(ProgressPageBaseTests):
"""Test that query counts remain the same for self-paced and instructor-paced courses.""" """Test that query counts remain the same for self-paced and instructor-paced courses."""
SelfPacedConfiguration(enabled=self_paced_enabled).save() SelfPacedConfiguration(enabled=self_paced_enabled).save()
self.setup_course(self_paced=self_paced) self.setup_course(self_paced=self_paced)
with self.assertNumQueries(40), check_mongo_calls(1): with self.assertNumQueries(41), check_mongo_calls(1):
self._get_progress_page() self._get_progress_page()
@ddt.data( @ddt.data(
(False, 40, 26), (False, 41, 27),
(True, 33, 22) (True, 34, 23)
) )
@ddt.unpack @ddt.unpack
def test_progress_queries(self, enable_waffle, initial, subsequent): def test_progress_queries(self, enable_waffle, initial, subsequent):

View File

@@ -86,9 +86,9 @@ from openedx.core.djangoapps.programs.utils import ProgramMarketingDataExtender
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.features.course_experience import ( from openedx.features.course_experience import (
UNIFIED_COURSE_EXPERIENCE_FLAG, UNIFIED_COURSE_TAB_FLAG,
UNIFIED_COURSE_VIEW_FLAG, UNIFIED_COURSE_VIEW_FLAG,
course_home_url_name course_home_url_name,
) )
from openedx.features.course_experience.views.course_dates import CourseDatesFragmentView from openedx.features.course_experience.views.course_dates import CourseDatesFragmentView
from openedx.features.enterprise_support.api import data_sharing_consent_required from openedx.features.enterprise_support.api import data_sharing_consent_required
@@ -263,11 +263,12 @@ def course_info(request, course_id):
return url return url
return None return None
# If the unified course experience is enabled, redirect to the "Course" tab
if waffle.flag_is_active(request, UNIFIED_COURSE_EXPERIENCE_FLAG):
return redirect(reverse(course_home_url_name(request), args=[course_id]))
course_key = CourseKey.from_string(course_id) course_key = CourseKey.from_string(course_id)
# If the unified course experience is enabled, redirect to the "Course" tab
if UNIFIED_COURSE_TAB_FLAG.is_enabled(course_key):
return redirect(reverse(course_home_url_name(course_key), args=[course_id]))
with modulestore().bulk_operations(course_key): with modulestore().bulk_operations(course_key):
course = get_course_by_id(course_key, depth=2) course = get_course_by_id(course_key, depth=2)
access_response = has_access(request.user, 'load', course, course_key) access_response = has_access(request.user, 'load', course, course_key)
@@ -669,7 +670,7 @@ def course_about(request, course_id):
modes = CourseMode.modes_for_course_dict(course_key) modes = CourseMode.modes_for_course_dict(course_key)
if configuration_helpers.get_value('ENABLE_MKTG_SITE', settings.FEATURES.get('ENABLE_MKTG_SITE', False)): if configuration_helpers.get_value('ENABLE_MKTG_SITE', settings.FEATURES.get('ENABLE_MKTG_SITE', False)):
return redirect(reverse(course_home_url_name(request), args=[unicode(course.id)])) return redirect(reverse(course_home_url_name(course.id), args=[unicode(course.id)]))
registered = registered_for_course(course, request.user) registered = registered_for_course(course, request.user)
@@ -677,7 +678,7 @@ def course_about(request, course_id):
studio_url = get_studio_url(course, 'settings/details') studio_url = get_studio_url(course, 'settings/details')
if has_access(request.user, 'load', course): if has_access(request.user, 'load', course):
course_target = reverse(course_home_url_name(request), args=[course.id.to_deprecated_string()]) course_target = reverse(course_home_url_name(course.id), args=[course.id.to_deprecated_string()])
else: else:
course_target = reverse('about_course', args=[course.id.to_deprecated_string()]) course_target = reverse('about_course', args=[course.id.to_deprecated_string()])
@@ -1241,7 +1242,7 @@ def course_survey(request, course_id):
course_key = CourseKey.from_string(course_id) course_key = CourseKey.from_string(course_id)
course = get_course_with_access(request.user, 'load', course_key) course = get_course_with_access(request.user, 'load', course_key)
redirect_url = reverse(course_home_url_name(request), args=[course_id]) redirect_url = reverse(course_home_url_name(course.id), args=[course_id])
# if there is no Survey associated with this course, # if there is no Survey associated with this course,
# then redirect to the course instead # then redirect to the course instead

View File

@@ -2,7 +2,7 @@
This module contains various configuration settings via This module contains various configuration settings via
waffle switches for the Grades app. waffle switches for the Grades app.
""" """
from openedx.core.djangolib.waffle_utils import WaffleSwitchPlus from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace
# Namespace # Namespace
@@ -18,4 +18,4 @@ def waffle():
""" """
Returns the namespaced, cached, audited Waffle class for Grades. Returns the namespaced, cached, audited Waffle class for Grades.
""" """
return WaffleSwitchPlus(namespace=WAFFLE_NAMESPACE, log_prefix=u'Grades: ') return WaffleSwitchNamespace(name=WAFFLE_NAMESPACE, log_prefix=u'Grades: ')

View File

@@ -2211,6 +2211,9 @@ INSTALLED_APPS = (
# Unusual migrations # Unusual migrations
'database_fixups', 'database_fixups',
# Waffle related utilities
'openedx.core.djangoapps.waffle_utils',
# Features # Features
'openedx.features.course_bookmarks', 'openedx.features.course_bookmarks',
'openedx.features.course_experience', 'openedx.features.course_experience',

View File

@@ -56,7 +56,7 @@ from util.course import get_link_for_about_page, get_encoded_course_sharing_utm_
% endif % endif
<div class="course-container"> <div class="course-container">
<article class="course${mode_class}"> <article class="course${mode_class}">
<% course_target = reverse(course_home_url_name(), args=[unicode(course_overview.id)]) %> <% course_target = reverse(course_home_url_name(course_overview.id), args=[unicode(course_overview.id)]) %>
<section class="details" aria-labelledby="details-heading-${course_overview.number}"> <section class="details" aria-labelledby="details-heading-${course_overview.number}">
<h2 class="hd hd-2 sr" id="details-heading-${course_overview.number}">${_('Course details')}</h2> <h2 class="hd hd-2 sr" id="details-heading-${course_overview.number}">${_('Course details')}</h2>
<div class="wrapper-course-image" aria-hidden="true"> <div class="wrapper-course-image" aria-hidden="true">

View File

@@ -75,7 +75,7 @@ from openedx.features.course_experience import course_home_url_name
</div> </div>
% if not reg_code_already_redeemed: % if not reg_code_already_redeemed:
%if redemption_success: %if redemption_success:
<% course_url = reverse(course_home_url_name(), args=[course.id.to_deprecated_string()]) %> <% course_url = reverse(course_home_url_name(course.id), args=[course.id.to_deprecated_string()]) %>
<a href="${course_url}" class="link-button course-link-bg-color">${_("View Course")} <span class="icon fa fa-caret-right" aria-hidden="true"></span></a> <a href="${course_url}" class="link-button course-link-bg-color">${_("View Course")} <span class="icon fa fa-caret-right" aria-hidden="true"></span></a>
%elif not registered_for_course: %elif not registered_for_course:
<form method="post"> <form method="post">

View File

@@ -55,6 +55,6 @@ class HelpModalTests(ModuleStoreTestCase):
Simple test to make sure that you don't get a 500 error when the modal Simple test to make sure that you don't get a 500 error when the modal
is enabled. is enabled.
""" """
url = reverse(course_home_url_name(), args=[self.course.id.to_deprecated_string()]) url = reverse(course_home_url_name(self.course.id), args=[self.course.id.to_deprecated_string()])
resp = self.client.get(url) resp = self.client.get(url)
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)

View File

@@ -2,7 +2,7 @@
This module contains various configuration settings via This module contains various configuration settings via
waffle switches for the Block Structure framework. waffle switches for the Block Structure framework.
""" """
from openedx.core.djangolib.waffle_utils import WaffleSwitchPlus from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace
from request_cache.middleware import request_cached from request_cache.middleware import request_cached
from .models import BlockStructureConfiguration from .models import BlockStructureConfiguration
@@ -22,7 +22,7 @@ def waffle():
""" """
Returns the namespaced and cached Waffle class for BlockStructures. Returns the namespaced and cached Waffle class for BlockStructures.
""" """
return WaffleSwitchPlus(namespace=WAFFLE_NAMESPACE, log_prefix=u'BlockStructure: ') return WaffleSwitchNamespace(name=WAFFLE_NAMESPACE, log_prefix=u'BlockStructure: ')
@request_cached @request_cached

View File

@@ -19,7 +19,7 @@ except ImportError:
import psutil import psutil
import request_cache import request_cache
from openedx.core.djangolib.waffle_utils import WaffleSwitchPlus from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace
REQUEST_CACHE_KEY = 'monitoring_custom_metrics' REQUEST_CACHE_KEY = 'monitoring_custom_metrics'
@@ -163,4 +163,4 @@ class MonitoringMemoryMiddleware(object):
""" """
Returns whether this middleware is enabled. Returns whether this middleware is enabled.
""" """
return WaffleSwitchPlus(namespace=WAFFLE_NAMESPACE).is_enabled(u'enable_memory_middleware') return WaffleSwitchNamespace(name=WAFFLE_NAMESPACE).is_enabled(u'enable_memory_middleware')

View File

@@ -0,0 +1,298 @@
"""
Utilities for waffle.
Includes namespacing, caching, and course overrides for waffle flags.
Usage:
For Waffle Flags, first set up the namespace, and then create flags using the
namespace. For example:
WAFFLE_FLAG_NAMESPACE = WaffleFlagNamespace(name='course_experience')
HIDE_SEARCH_FLAG = WaffleFlag(WAFFLE_FLAG_NAMESPACE, 'hide_search')
UNIFIED_COURSE_TAB_FLAG = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'unified_course_tab')
You can check these flags in code using the following:
HIDE_SEARCH_FLAG.is_enabled()
UNIFIED_COURSE_TAB_FLAG.is_enabled(course_key)
To test these WaffleFlags, see testutils.py.
In the above examples, you will use Django Admin "waffle" section to configure
for a flag named: course_experience.unified_course_tab
You could also use the Django Admin "waffle_utils" section to configure a course
override for this same flag (e.g. course_experience.unified_course_tab).
For Waffle Switches, first set up the namespace, and then create the flag name.
For example:
WAFFLE_SWITCHES = WaffleSwitchNamespace(name=WAFFLE_NAMESPACE)
ESTIMATE_FIRST_ATTEMPTED = 'estimate_first_attempted'
You can then use the switch as follows:
WAFFLE_SWITCHES.is_enabled(waffle.ESTIMATE_FIRST_ATTEMPTED)
To test WaffleSwitchNamespace, use the provided context managers. For example:
with WAFFLE_SWITCHES.override(waffle.ESTIMATE_FIRST_ATTEMPTED, active=True):
...
"""
from abc import ABCMeta
from contextlib import contextmanager
import logging
from waffle.testutils import override_switch as waffle_override_switch
from waffle import flag_is_active, switch_is_active
from opaque_keys.edx.keys import CourseKey
from request_cache import get_request, get_cache as get_request_cache
from .models import WaffleFlagCourseOverrideModel
log = logging.getLogger(__name__)
class WaffleNamespace(object):
"""
A base class for a request cached namespace for waffle flags/switches.
An instance of this class represents a single namespace
(e.g. "course_experience"), and can be used to work with a set of
flags or switches that will all share this namespace.
"""
__metaclass__ = ABCMeta
def __init__(self, name, log_prefix=None):
"""
Initializes the waffle namespace instance.
Arguments:
name (String): Namespace string appended to start of all waffle
flags and switches (e.g. "grades")
log_prefix (String): Optional string to be appended to log messages
(e.g. "Grades: "). Defaults to ''.
"""
assert name, "The name is required."
self.name = name
self.log_prefix = log_prefix if log_prefix else ''
def _namespaced_name(self, setting_name):
"""
Returns the namespaced name of the waffle switch/flag.
For example, the namespaced name of a waffle switch/flag would be:
my_namespace.my_setting_name
Arguments:
setting_name (String): The name of the flag or switch.
"""
return u'{}.{}'.format(self.name, setting_name)
@staticmethod
def _get_request_cache():
"""
Returns a request cache shared by all instances of this class.
"""
return get_request_cache('WaffleNamespace')
class WaffleSwitchNamespace(WaffleNamespace):
"""
Provides a single namespace for a set of waffle switches.
All namespaced switch values are stored in a single request cache containing
all switches for all namespaces.
"""
def is_enabled(self, switch_name):
"""
Returns and caches whether the given waffle switch is enabled.
"""
namespaced_switch_name = self._namespaced_name(switch_name)
value = self._cached_switches.get(namespaced_switch_name)
if value is None:
value = switch_is_active(namespaced_switch_name)
self._cached_switches[namespaced_switch_name] = value
return value
@contextmanager
def override(self, switch_name, active=True):
"""
Overrides the active value for the given switch for the duration of this
contextmanager.
Note: The value is overridden in the request cache AND in the model.
"""
previous_active = self.is_enabled(switch_name)
try:
self.override_for_request(switch_name, active)
with self.override_in_model(switch_name, active):
yield
finally:
self.override_for_request(switch_name, previous_active)
def override_for_request(self, switch_name, active=True):
"""
Overrides the active value for the given switch for the remainder of
this request (as this is not a context manager).
Note: The value is overridden in the request cache, not in the model.
"""
namespaced_switch_name = self._namespaced_name(switch_name)
self._cached_switches[namespaced_switch_name] = active
log.info(u"%sSwitch '%s' set to %s for request.", self.log_prefix, namespaced_switch_name, active)
@contextmanager
def override_in_model(self, switch_name, active=True):
"""
Overrides the active value for the given switch for the duration of this
contextmanager.
Note: The value is overridden in the model, not the request cache.
"""
namespaced_switch_name = self._namespaced_name(switch_name)
with waffle_override_switch(namespaced_switch_name, active):
log.info(u"%sSwitch '%s' set to %s in model.", self.log_prefix, namespaced_switch_name, active)
yield
@property
def _cached_switches(self):
"""
Returns a dictionary of all namespaced switches in the request cache.
"""
return self._get_request_cache().setdefault('switches', {})
class WaffleFlagNamespace(WaffleNamespace):
"""
Provides a single namespace for a set of waffle flags.
All namespaced flag values are stored in a single request cache containing
all flags for all namespaces.
"""
__metaclass__ = ABCMeta
@property
def _cached_flags(self):
"""
Returns a dictionary of all namespaced flags in the request cache.
"""
return self._get_request_cache().setdefault('flags', {})
def is_flag_active(self, flag_name, check_before_waffle_callback=None):
"""
Returns and caches whether the provided flag is active.
If the flag value is already cached in the request, it is returned.
If check_before_waffle_callback is supplied, it is called before
checking waffle.
If check_before_waffle_callback returns None, or if it is not supplied,
then waffle is used to check the flag.
Arguments:
flag_name (String): The name of the flag to check.
check_before_waffle_callback (function): (Optional) A function that
will be checked before continuing on to waffle. If
check_before_waffle_callback(namespaced_flag_name) returns True
or False, it is cached and returned. If it returns None, then
waffle is used.
"""
# validate arguments
namespaced_flag_name = self._namespaced_name(flag_name)
value = self._cached_flags.get(namespaced_flag_name)
if value is None:
if check_before_waffle_callback:
value = check_before_waffle_callback(namespaced_flag_name)
if value is None:
value = flag_is_active(get_request(), namespaced_flag_name)
self._cached_flags[namespaced_flag_name] = value
return value
class WaffleFlag(object):
"""
Represents a single waffle flag, using a cached waffle namespace.
"""
def __init__(self, waffle_namespace, flag_name):
"""
Initializes the waffle flag instance.
Arguments:
waffle_namespace (WaffleFlagNamespace): Provides a cached namespace
for this flag.
flag_name (String): The name of the flag (without namespacing).
"""
self.waffle_namespace = waffle_namespace
self.flag_name = flag_name
def is_enabled(self):
"""
Returns whether or not the flag is enabled.
"""
return self.waffle_namespace.is_flag_active(self.flag_name)
class CourseWaffleFlag(WaffleFlag):
"""
Represents a single waffle flag that can be forced on/off for a course.
Uses a cached waffle namespace.
"""
def _get_course_override_callback(self, course_id):
"""
Returns a function to use as the check_before_waffle_callback.
Arguments:
course_id (CourseKey): The course to check for override before
checking waffle.
"""
def course_override_callback(namespaced_flag_name):
"""
Returns True/False if the flag was forced on or off for the provided
course. Returns None if the flag was not overridden.
Arguments:
namespaced_flag_name (String): A namespaced version of the flag
to check.
"""
force_override = WaffleFlagCourseOverrideModel.override_value(namespaced_flag_name, course_id)
if force_override == WaffleFlagCourseOverrideModel.ALL_CHOICES.on:
return True
if force_override == WaffleFlagCourseOverrideModel.ALL_CHOICES.off:
return False
return None
return course_override_callback
def is_enabled(self, course_id=None):
"""
Returns whether or not the flag is enabled.
Arguments:
course_id (CourseKey): The course to check for override before
checking waffle.
"""
# validate arguments
assert issubclass(type(course_id), CourseKey), "The course_id '{}' must be a CourseKey.".format(str(course_id))
return self.waffle_namespace.is_flag_active(
self.flag_name,
check_before_waffle_callback=self._get_course_override_callback(course_id)
)

View File

@@ -0,0 +1,29 @@
"""
Django admin page for waffle utils models
"""
from django.contrib import admin
from config_models.admin import ConfigurationModelAdmin, KeyedConfigurationModelAdmin
from .forms import WaffleFlagCourseOverrideAdminForm
from .models import WaffleFlagCourseOverrideModel
class WaffleFlagCourseOverrideAdmin(KeyedConfigurationModelAdmin):
"""
Admin for course override of waffle flags.
Includes search by course_id and waffle_flag.
"""
form = WaffleFlagCourseOverrideAdminForm
search_fields = ['waffle_flag', 'course_id']
fieldsets = (
(None, {
'fields': ('waffle_flag', 'course_id', 'override_choice', 'enabled'),
'description': 'Enter a valid course id and an existing waffle flag. The waffle flag name is not validated.'
}),
)
admin.site.register(WaffleFlagCourseOverrideModel, WaffleFlagCourseOverrideAdmin)

View File

@@ -0,0 +1,35 @@
"""
Defines a form for providing validation of subsection grade templates.
"""
from django import forms
from openedx.core.lib.courses import clean_course_id
from .models import WaffleFlagCourseOverrideModel
class WaffleFlagCourseOverrideAdminForm(forms.ModelForm):
"""
Input form for course override of waffle flags, allowing us to verify data.
"""
class Meta(object):
model = WaffleFlagCourseOverrideModel
fields = '__all__'
def clean_course_id(self):
"""
Validate the course id
"""
return clean_course_id(self)
def clean_waffle_flag(self):
"""
Validate the waffle flag is an existing flag.
"""
cleaned_flag = self.cleaned_data['waffle_flag']
if not cleaned_flag:
msg = u'Waffle flag must be supplied.'
raise forms.ValidationError(msg)
return cleaned_flag.strip()

View File

@@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import openedx.core.djangoapps.xmodule_django.models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='WaffleFlagCourseOverrideModel',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')),
('enabled', models.BooleanField(default=False, verbose_name='Enabled')),
('waffle_flag', models.CharField(max_length=255, db_index=True)),
('course_id', openedx.core.djangoapps.xmodule_django.models.CourseKeyField(max_length=255, db_index=True)),
('override_choice', models.CharField(default=b'on', max_length=3, choices=[(b'on', 'Force On'), (b'off', 'Force Off')])),
('changed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, editable=False, to=settings.AUTH_USER_MODEL, null=True, verbose_name='Changed by')),
],
options={
'verbose_name': 'Waffle flag course override',
'verbose_name_plural': 'Waffle flag course overrides',
},
),
]

View File

@@ -0,0 +1,61 @@
"""
Models for configuring waffle utils.
"""
from django.db.models import CharField
from django.utils.translation import ugettext_lazy as _
from model_utils import Choices
from config_models.models import ConfigurationModel
from openedx.core.djangoapps.xmodule_django.models import CourseKeyField
from request_cache.middleware import request_cached
class WaffleFlagCourseOverrideModel(ConfigurationModel):
"""
Used to force a waffle flag on or off for a course.
"""
OVERRIDE_CHOICES = Choices(('on', _('Force On')), ('off', _('Force Off')))
ALL_CHOICES = OVERRIDE_CHOICES + Choices('unset')
KEY_FIELDS = ('waffle_flag', 'course_id')
# The course that these features are attached to.
waffle_flag = CharField(max_length=255, db_index=True)
course_id = CourseKeyField(max_length=255, db_index=True)
override_choice = CharField(choices=OVERRIDE_CHOICES, default=OVERRIDE_CHOICES.on, max_length=3)
@classmethod
@request_cached
def override_value(cls, waffle_flag, course_id):
"""
Returns whether the waffle flag was overridden (on or off) for the
course, or is unset.
Arguments:
waffle_flag (String): The name of the flag.
course_id (CourseKey): The course id for which the flag may have
been overridden.
If the current config is not set or disabled for this waffle flag and
course id, returns ALL_CHOICES.unset.
Otherwise, returns ALL_CHOICES.on or ALL_CHOICES.off as configured for
the override_choice.
"""
if not course_id or not waffle_flag:
return cls.ALL_CHOICES.unset
effective = cls.objects.filter(waffle_flag=waffle_flag, course_id=course_id).order_by('-change_date').first()
if effective and effective.enabled:
return effective.override_choice
return cls.ALL_CHOICES.unset
class Meta(object):
app_label = "waffle_utils"
verbose_name = 'Waffle flag course override'
verbose_name_plural = 'Waffle flag course overrides'
def __unicode__(self):
enabled_label = "Enabled" if self.enabled else "Not Enabled"
# pylint: disable=no-member
return u"Course '{}': Persistent Grades {}".format(self.course_id.to_deprecated_string(), enabled_label)

View File

@@ -0,0 +1,52 @@
"""
Tests for waffle utils features.
"""
import ddt
from django.test import TestCase
from mock import patch
from opaque_keys.edx.keys import CourseKey
from waffle.testutils import override_flag
from request_cache.middleware import RequestCache
from .. import CourseWaffleFlag, WaffleFlagNamespace
from ..models import WaffleFlagCourseOverrideModel
@ddt.ddt
class TestCourseWaffleFlag(TestCase):
"""
Tests the CourseWaffleFlag.
"""
NAMESPACE_NAME = "test_namespace"
FLAG_NAME = "test_flag"
NAMESPACED_FLAG_NAME = NAMESPACE_NAME + "." + FLAG_NAME
TEST_COURSE_KEY = CourseKey.from_string("edX/DemoX/Demo_Course")
TEST_NAMESPACE = WaffleFlagNamespace(NAMESPACE_NAME)
TEST_COURSE_FLAG = CourseWaffleFlag(TEST_NAMESPACE, FLAG_NAME)
@ddt.data(
{'course_override': WaffleFlagCourseOverrideModel.ALL_CHOICES.on, 'waffle_enabled': False, 'result': True},
{'course_override': WaffleFlagCourseOverrideModel.ALL_CHOICES.off, 'waffle_enabled': True, 'result': False},
{'course_override': WaffleFlagCourseOverrideModel.ALL_CHOICES.unset, 'waffle_enabled': True, 'result': True},
{'course_override': WaffleFlagCourseOverrideModel.ALL_CHOICES.unset, 'waffle_enabled': False, 'result': False},
)
def test_course_waffle_flag(self, data):
"""
Tests various combinations of a flag being set in waffle and overridden
for a course.
"""
RequestCache.clear_request_cache()
with patch.object(WaffleFlagCourseOverrideModel, 'override_value', return_value=data['course_override']):
with override_flag(self.NAMESPACED_FLAG_NAME, active=data['waffle_enabled']):
# check twice to test that the result is properly cached
self.assertEqual(self.TEST_COURSE_FLAG.is_enabled(self.TEST_COURSE_KEY), data['result'])
self.assertEqual(self.TEST_COURSE_FLAG.is_enabled(self.TEST_COURSE_KEY), data['result'])
# result is cached, so override check should happen once
WaffleFlagCourseOverrideModel.override_value.assert_called_once_with(
self.NAMESPACED_FLAG_NAME,
self.TEST_COURSE_KEY
)

View File

@@ -0,0 +1,51 @@
"""
Tests for waffle utils models.
"""
from ddt import data, ddt, unpack
from django.test import TestCase
from opaque_keys.edx.keys import CourseKey
from request_cache.middleware import RequestCache
from ..models import WaffleFlagCourseOverrideModel
@ddt
class WaffleFlagCourseOverrideTests(TestCase):
"""
Tests for the waffle flag course override model.
"""
WAFFLE_TEST_NAME = "waffle_test_course_override"
TEST_COURSE_KEY = CourseKey.from_string("edX/DemoX/Demo_Course")
OVERRIDE_CHOICES = WaffleFlagCourseOverrideModel.ALL_CHOICES
# Data format: ( is_enabled, override_choice, expected_result )
@data((True, OVERRIDE_CHOICES.on, OVERRIDE_CHOICES.on),
(True, OVERRIDE_CHOICES.off, OVERRIDE_CHOICES.off),
(False, OVERRIDE_CHOICES.on, OVERRIDE_CHOICES.unset))
@unpack
def test_setting_override(self, is_enabled, override_choice, expected_result):
RequestCache.clear_request_cache()
self.set_waffle_course_override(override_choice, is_enabled)
override_value = WaffleFlagCourseOverrideModel.override_value(
self.WAFFLE_TEST_NAME, self.TEST_COURSE_KEY
)
self.assertEqual(override_value, expected_result)
def test_setting_override_multiple_times(self):
RequestCache.clear_request_cache()
self.set_waffle_course_override(self.OVERRIDE_CHOICES.on)
self.set_waffle_course_override(self.OVERRIDE_CHOICES.off)
override_value = WaffleFlagCourseOverrideModel.override_value(
self.WAFFLE_TEST_NAME, self.TEST_COURSE_KEY
)
self.assertEqual(override_value, self.OVERRIDE_CHOICES.off)
def set_waffle_course_override(self, override_choice, is_enabled=True):
WaffleFlagCourseOverrideModel.objects.create(
waffle_flag=self.WAFFLE_TEST_NAME,
override_choice=override_choice,
enabled=is_enabled,
course_id=self.TEST_COURSE_KEY
)

View File

@@ -0,0 +1,57 @@
"""
Tests for waffle utils test utilities.
"""
from django.test import TestCase
from opaque_keys.edx.keys import CourseKey
from request_cache.middleware import RequestCache
from .. import CourseWaffleFlag, WaffleFlagNamespace
from ..testutils import override_waffle_flag
class OverrideWaffleFlagTests(TestCase):
"""
Tests for the override_waffle_flag decorator.
"""
NAMESPACE_NAME = "test_namespace"
FLAG_NAME = "test_flag"
NAMESPACED_FLAG_NAME = NAMESPACE_NAME + "." + FLAG_NAME
TEST_COURSE_KEY = CourseKey.from_string("edX/DemoX/Demo_Course")
TEST_NAMESPACE = WaffleFlagNamespace(NAMESPACE_NAME)
TEST_COURSE_FLAG = CourseWaffleFlag(TEST_NAMESPACE, FLAG_NAME)
def setUp(self):
super(OverrideWaffleFlagTests, self).setUp()
RequestCache.clear_request_cache()
@override_waffle_flag(TEST_COURSE_FLAG, True)
def check_is_enabled_with_decorator(self):
# test flag while overridden with decorator
self.assertTrue(self.TEST_COURSE_FLAG.is_enabled(self.TEST_COURSE_KEY))
def test_override_waffle_flag_pre_cached(self):
# checks and caches the is_enabled value
self.assertFalse(self.TEST_COURSE_FLAG.is_enabled(self.TEST_COURSE_KEY))
flag_cache = self.TEST_COURSE_FLAG.waffle_namespace._cached_flags
self.assertIn(self.NAMESPACED_FLAG_NAME, flag_cache)
# test flag while overridden with decorator
self.check_is_enabled_with_decorator()
# test cached flag is restored
self.assertIn(self.NAMESPACED_FLAG_NAME, flag_cache)
self.assertEquals(self.TEST_COURSE_FLAG.is_enabled(self.TEST_COURSE_KEY), False)
def test_override_waffle_flag_not_pre_cached(self):
# check that the flag is not yet cached
flag_cache = self.TEST_COURSE_FLAG.waffle_namespace._cached_flags
self.assertNotIn(self.NAMESPACED_FLAG_NAME, flag_cache)
# test flag while overridden with decorator
self.check_is_enabled_with_decorator()
# test cache is removed when no longer using decorator/context manager
self.assertNotIn(self.NAMESPACED_FLAG_NAME, flag_cache)

View File

@@ -0,0 +1,60 @@
"""
Test utilities for waffle utilities.
"""
from functools import wraps
from waffle.testutils import override_flag
def override_waffle_flag(flag, active):
"""
To be used as a decorator for a test function to override a namespaced
waffle flag.
flag (WaffleFlag): The namespaced cached waffle flag.
active (Boolean): The value to which the flag will be set.
Example usage:
@override_waffle_flag(UNIFIED_COURSE_TAB_FLAG, active=True)
"""
def real_decorator(function):
"""
Actual decorator function.
"""
@wraps(function)
def wrapper(*args, **kwargs):
"""
Provides the actual override functionality of the decorator.
Saves the previous cached value of the flag and restores it (if it
was set), after overriding it.
"""
waffle_namespace = flag.waffle_namespace
namespaced_flag_name = waffle_namespace._namespaced_name(flag.flag_name)
# save previous value and whether it existed in the cache
cached_value_existed = namespaced_flag_name in waffle_namespace._cached_flags
if cached_value_existed:
previous_value = waffle_namespace._cached_flags[namespaced_flag_name]
# set new value
waffle_namespace._cached_flags[namespaced_flag_name] = active
with override_flag(namespaced_flag_name, active):
# call wrapped function
function(*args, **kwargs)
# restore value
if cached_value_existed:
waffle_namespace._cached_flags[namespaced_flag_name] = previous_value
elif namespaced_flag_name in waffle_namespace._cached_flags:
del waffle_namespace._cached_flags[namespaced_flag_name]
return wrapper
return real_decorator

View File

@@ -1,108 +0,0 @@
"""
Utilities for waffle usage.
"""
import logging
from abc import ABCMeta
from contextlib import contextmanager
from waffle import switch_is_active
from waffle.testutils import override_switch as waffle_override_switch
from request_cache import get_cache as get_request_cache
log = logging.getLogger(__name__)
class WafflePlus(object):
"""
Waffle helper class that provides native support for
namespacing waffle settings and caching within a request.
"""
__metaclass__ = ABCMeta
def __init__(self, namespace, log_prefix=None):
self.namespace = namespace
self.log_prefix = log_prefix
def _namespaced_setting_name(self, setting_name):
"""
Returns the namespaced name of the waffle switch/flag.
"""
assert self.namespace is not None
return u'{}.{}'.format(self.namespace, setting_name)
@staticmethod
def _get_request_cache():
"""
Returns the request cache used by WafflePlus classes.
"""
return get_request_cache('WafflePlus')
class WaffleSwitchPlus(WafflePlus):
"""
Waffle Switch helper class that provides native support for
namespacing waffle switches and caching within a request.
"""
def is_enabled(self, switch_name):
"""
Returns and caches whether the given waffle switch is enabled.
"""
namespaced_switch_name = self._namespaced_setting_name(switch_name)
value = self._cached_switches.get(namespaced_switch_name)
if value is None:
value = switch_is_active(namespaced_switch_name)
self._cached_switches[namespaced_switch_name] = value
return value
@contextmanager
def override(self, switch_name, active=True):
"""
Overrides the active value for the given switch for the duration of this
contextmanager.
Note: The value is overridden in the request cache AND in the model.
"""
previous_active = self.is_enabled(switch_name)
try:
self.override_for_request(switch_name, active)
with self.override_in_model(switch_name, active):
yield
finally:
self.override_for_request(switch_name, previous_active)
def override_for_request(self, switch_name, active=True):
"""
Overrides the active value for the given switch for the remainder of
this request (as this is not a context manager).
Note: The value is overridden in the request cache, not in the model.
"""
namespaced_switch_name = self._namespaced_setting_name(switch_name)
self._cached_switches[namespaced_switch_name] = active
log.info(u"%sSwitch '%s' set to %s for request.", self.log_prefix, namespaced_switch_name, active)
@contextmanager
def override_in_model(self, switch_name, active=True):
"""
Overrides the active value for the given switch for the duration of this
contextmanager.
Note: The value is overridden in the model, not the request cache.
"""
namespaced_switch_name = self._namespaced_setting_name(switch_name)
with waffle_override_switch(namespaced_switch_name, active):
log.info(u"%sSwitch '%s' set to %s in model.", self.log_prefix, namespaced_switch_name, active)
yield
@property
def _cached_switches(self):
"""
Returns cached active values of all switches in this namespace.
"""
return self._all_cached_switches.setdefault(self.namespace, {})
@property
def _all_cached_switches(self):
"""
Returns dictionary of all switches in the request cache,
keyed by namespace.
"""
return self._get_request_cache().setdefault('switches', {})

View File

@@ -1,11 +1,15 @@
""" """
Common utility functions related to courses. Common utility functions related to courses.
""" """
from django import forms
from django.conf import settings from django.conf import settings
from opaque_keys import InvalidKeyError
from opaque_keys.edx.locator import CourseKey
from xmodule.assetstore.assetmgr import AssetManager from xmodule.assetstore.assetmgr import AssetManager
from xmodule.contentstore.content import StaticContent from xmodule.contentstore.content import StaticContent
from xmodule.contentstore.django import contentstore from xmodule.contentstore.django import contentstore
from xmodule.modulestore.django import modulestore
def course_image_url(course, image_key='course_image'): def course_image_url(course, image_key='course_image'):
@@ -43,3 +47,37 @@ def create_course_image_thumbnail(course, dimensions):
_content, thumb_loc = contentstore().generate_thumbnail(course_image, dimensions=dimensions) _content, thumb_loc = contentstore().generate_thumbnail(course_image, dimensions=dimensions)
return StaticContent.serialize_asset_key_with_slash(thumb_loc) return StaticContent.serialize_asset_key_with_slash(thumb_loc)
def clean_course_id(model_form, is_required=True):
"""
Cleans and validates a course_id for use with a Django ModelForm.
Arguments:
model_form (form.ModelForm): The form that has a course_id.
is_required (Boolean): Default True. When True, validates that the
course_id is not empty. In all cases, when course_id is supplied,
validates that it is a valid course.
Returns:
(CourseKey) The cleaned and validated course_id as a CourseKey.
NOTE: This should ultimately replace all copies of "def clean_course_id".
"""
cleaned_id = model_form.cleaned_data["course_id"]
if not cleaned_id and not is_required:
return None
try:
course_key = CourseKey.from_string(cleaned_id)
except InvalidKeyError:
msg = u'Course id invalid. Entered course id was: "{0}."'.format(cleaned_id)
raise forms.ValidationError(msg)
if not modulestore().has_course(course_key):
msg = u'Course not found. Entered course id was: "{0}". '.format(course_key.to_deprecated_string())
raise forms.ValidationError(msg)
return course_key

View File

@@ -1,18 +1,22 @@
""" """
Unified course experience settings and helper methods. Unified course experience settings and helper methods.
""" """
import waffle import waffle
from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag, WaffleFlagNamespace
from request_cache.middleware import RequestCache from request_cache.middleware import RequestCache
# Waffle flag to enable a single unified "Course" tab. # Waffle flag to enable the full screen course content view along with a unified
UNIFIED_COURSE_EXPERIENCE_FLAG = 'unified_course_experience' # course home page.
# NOTE: This is the only legacy flag that does not use the namespace.
# Waffle flag to enable the full screen course content view
# along with a unified course home page.
UNIFIED_COURSE_VIEW_FLAG = 'unified_course_view' UNIFIED_COURSE_VIEW_FLAG = 'unified_course_view'
# Namespace for course experience waffle flags.
WAFFLE_FLAG_NAMESPACE = WaffleFlagNamespace(name='course_experience')
# Waffle flag to enable a single unified "Course" tab.
UNIFIED_COURSE_TAB_FLAG = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'unified_course_tab')
def default_course_url_name(request=None): def default_course_url_name(request=None):
""" """
@@ -24,11 +28,16 @@ def default_course_url_name(request=None):
return 'courseware' return 'courseware'
def course_home_url_name(request=None): def course_home_url_name(course_key):
""" """
Returns the course home page's URL name for the current user. Returns the course home page's URL name for the current user.
Arguments:
course_key (CourseKey): The course key for which the home url is being
requested.
""" """
if waffle.flag_is_active(request or RequestCache.get_current_request(), UNIFIED_COURSE_EXPERIENCE_FLAG): if UNIFIED_COURSE_TAB_FLAG.is_enabled(course_key):
return 'openedx.course_experience.course_home' return 'openedx.course_experience.course_home'
else: else:
return 'info' return 'info'

View File

@@ -5,7 +5,6 @@
<%! <%!
import json import json
import waffle
from django.conf import settings from django.conf import settings
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
@@ -15,7 +14,7 @@ from django.core.urlresolvers import reverse
from django_comment_client.permissions import has_permission from django_comment_client.permissions import has_permission
from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_string from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_string
from openedx.core.djangolib.markup import HTML from openedx.core.djangolib.markup import HTML
from openedx.features.course_experience import UNIFIED_COURSE_EXPERIENCE_FLAG from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG
%> %>
<%block name="content"> <%block name="content">
@@ -58,7 +57,7 @@ from openedx.features.course_experience import UNIFIED_COURSE_EXPERIENCE_FLAG
<div class="page-content"> <div class="page-content">
<div class="layout layout-1q3q"> <div class="layout layout-1q3q">
<main class="layout-col layout-col-b"> <main class="layout-col layout-col-b">
% if welcome_message_fragment and waffle.flag_is_active(request, UNIFIED_COURSE_EXPERIENCE_FLAG): % if welcome_message_fragment and UNIFIED_COURSE_TAB_FLAG.is_enabled(course.id):
<div class="section section-dates"> <div class="section section-dates">
${HTML(welcome_message_fragment.body_html())} ${HTML(welcome_message_fragment.body_html())}
</div> </div>
@@ -76,7 +75,7 @@ from openedx.features.course_experience import UNIFIED_COURSE_EXPERIENCE_FLAG
${_("Bookmarks")} ${_("Bookmarks")}
</a> </a>
</li> </li>
% if waffle.flag_is_active(request, UNIFIED_COURSE_EXPERIENCE_FLAG): % if UNIFIED_COURSE_TAB_FLAG.is_enabled(course.id):
<li> <li>
<a href="${reverse('openedx.course_experience.course_updates', args=[course.id])}"> <a href="${reverse('openedx.course_experience.course_updates', args=[course.id])}">
<span class="icon fa fa-newspaper-o" aria-hidden="true"></span> <span class="icon fa fa-newspaper-o" aria-hidden="true"></span>

View File

@@ -4,17 +4,9 @@
<%namespace name='static' file='../static_content.html'/> <%namespace name='static' file='../static_content.html'/>
<%! <%!
import json
from django.conf import settings
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.template.defaultfilters import escapejs
from django.core.urlresolvers import reverse
from django_comment_client.permissions import has_permission
from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_string
from openedx.core.djangolib.markup import HTML from openedx.core.djangolib.markup import HTML
from openedx.features.course_experience import UNIFIED_COURSE_EXPERIENCE_FLAG
%> %>
<%block name="content"> <%block name="content">

View File

@@ -2,18 +2,16 @@
Tests for the course home page. Tests for the course home page.
""" """
from waffle.testutils import override_flag
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from openedx.core.djangoapps.waffle_utils.testutils import override_waffle_flag
from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG
from student.models import CourseEnrollment from student.models import CourseEnrollment
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, check_mongo_calls from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, check_mongo_calls
from openedx.features.course_experience import UNIFIED_COURSE_EXPERIENCE_FLAG
from .test_course_updates import create_course_update, remove_course_updates from .test_course_updates import create_course_update, remove_course_updates
TEST_PASSWORD = 'test' TEST_PASSWORD = 'test'
@@ -71,22 +69,13 @@ class TestCourseHomePage(SharedModuleStoreTestCase):
remove_course_updates(self.course) remove_course_updates(self.course)
super(TestCourseHomePage, self).tearDown() super(TestCourseHomePage, self).tearDown()
@override_flag(UNIFIED_COURSE_EXPERIENCE_FLAG, active=True) @override_waffle_flag(UNIFIED_COURSE_TAB_FLAG, active=True)
def test_unified_page(self):
"""
Verify the rendering of the unified page.
"""
url = course_home_url(self.course)
response = self.client.get(url)
self.assertContains(response, '<h2 class="hd hd-3 page-title">Test Course</h2>')
@override_flag(UNIFIED_COURSE_EXPERIENCE_FLAG, active=True)
def test_welcome_message_when_unified(self): def test_welcome_message_when_unified(self):
url = course_home_url(self.course) url = course_home_url(self.course)
response = self.client.get(url) response = self.client.get(url)
self.assertContains(response, TEST_WELCOME_MESSAGE, status_code=200) self.assertContains(response, TEST_WELCOME_MESSAGE, status_code=200)
@override_flag(UNIFIED_COURSE_EXPERIENCE_FLAG, active=False) @override_waffle_flag(UNIFIED_COURSE_TAB_FLAG, active=False)
def test_welcome_message_when_not_unified(self): def test_welcome_message_when_not_unified(self):
url = course_home_url(self.course) url = course_home_url(self.course)
response = self.client.get(url) response = self.client.get(url)
@@ -100,7 +89,7 @@ class TestCourseHomePage(SharedModuleStoreTestCase):
course_home_url(self.course) course_home_url(self.course)
# Fetch the view and verify the query counts # Fetch the view and verify the query counts
with self.assertNumQueries(43): with self.assertNumQueries(42):
with check_mongo_calls(5): with check_mongo_calls(5):
url = course_home_url(self.course) url = course_home_url(self.course)
self.client.get(url) self.client.get(url)

View File

@@ -124,7 +124,7 @@ class TestCourseUpdatesPage(SharedModuleStoreTestCase):
course_updates_url(self.course) course_updates_url(self.course)
# Fetch the view and verify that the query counts haven't changed # Fetch the view and verify that the query counts haven't changed
with self.assertNumQueries(32): with self.assertNumQueries(33):
with check_mongo_calls(4): with check_mongo_calls(4):
url = course_updates_url(self.course) url = course_updates_url(self.course)
self.client.get(url) self.client.get(url)